@stalfh233/omc-cli 0.0.0-stage → 0.4.10

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -3
  3. package/coverage/m1-coverage-manifest-v1.json +1278 -0
  4. package/dist/approval-token.js +102 -0
  5. package/dist/args.js +25 -0
  6. package/dist/artifacts.js +119 -0
  7. package/dist/bench/call-face-eval.js +256 -0
  8. package/dist/bench/context-attribution.js +151 -0
  9. package/dist/bench/discovery-cost-eval.js +230 -0
  10. package/dist/bench/driver.js +81 -0
  11. package/dist/bench/evals.js +236 -0
  12. package/dist/bench/fake-http-server.js +65 -0
  13. package/dist/bench/instrument.js +87 -0
  14. package/dist/bench/intent-face-eval.js +343 -0
  15. package/dist/bench/run.js +166 -0
  16. package/dist/bench/scenario.js +343 -0
  17. package/dist/bench/types.js +76 -0
  18. package/dist/bi-wire.js +41 -0
  19. package/dist/bizservice-config.js +581 -0
  20. package/dist/call.js +153 -0
  21. package/dist/capability-absences.js +23 -0
  22. package/dist/capability-overview.js +492 -0
  23. package/dist/capability-shape.js +154 -0
  24. package/dist/cli-contract.js +70 -0
  25. package/dist/cli-output.js +73 -0
  26. package/dist/cli.js +1418 -0
  27. package/dist/code-rules.js +69 -0
  28. package/dist/command-transport.js +155 -0
  29. package/dist/config-store.js +195 -0
  30. package/dist/context.js +21 -0
  31. package/dist/contract-consistency.js +66 -0
  32. package/dist/contract-resources.js +62 -0
  33. package/dist/coverage-consistency.js +62 -0
  34. package/dist/coverage-registry.js +76 -0
  35. package/dist/coverage.js +130 -0
  36. package/dist/data-list-filter.js +114 -0
  37. package/dist/discovery.js +390 -0
  38. package/dist/endpoints.js +154 -0
  39. package/dist/environment-policy.js +26 -0
  40. package/dist/execution-metadata.js +1092 -0
  41. package/dist/fake/app.js +45 -0
  42. package/dist/fake/b2-registration.js +594 -0
  43. package/dist/fake/businessrule.js +242 -0
  44. package/dist/fake/datarule.js +72 -0
  45. package/dist/fake/dictionary.js +108 -0
  46. package/dist/fake/environment.js +30 -0
  47. package/dist/fake/field.js +167 -0
  48. package/dist/fake/form.js +101 -0
  49. package/dist/fake/index.js +121 -0
  50. package/dist/fake/list-view.js +289 -0
  51. package/dist/fake/model.js +136 -0
  52. package/dist/fake/online-js.js +18 -0
  53. package/dist/fake/report.js +253 -0
  54. package/dist/fake/routes.js +47 -0
  55. package/dist/fake/rule-lifecycle.js +37 -0
  56. package/dist/fake/runtime-data.js +367 -0
  57. package/dist/fake/state.js +67 -0
  58. package/dist/fake/workflow.js +364 -0
  59. package/dist/field-change.js +200 -0
  60. package/dist/field-families.js +896 -0
  61. package/dist/form-layout.js +111 -0
  62. package/dist/form-support.js +821 -0
  63. package/dist/goal-routes.js +468 -0
  64. package/dist/governed-execution.js +87 -0
  65. package/dist/human-summary.js +212 -0
  66. package/dist/identity.js +62 -0
  67. package/dist/intent/baseline.js +57 -0
  68. package/dist/intent/capabilities/bizservice.js +274 -0
  69. package/dist/intent/capabilities/businessrule.js +493 -0
  70. package/dist/intent/capabilities/datarule.js +187 -0
  71. package/dist/intent/capabilities/field.js +545 -0
  72. package/dist/intent/capabilities/form.js +136 -0
  73. package/dist/intent/capabilities/index.js +64 -0
  74. package/dist/intent/capabilities/listview.js +157 -0
  75. package/dist/intent/capabilities/model.js +100 -0
  76. package/dist/intent/capabilities/onlinejs.js +108 -0
  77. package/dist/intent/capabilities/report.js +355 -0
  78. package/dist/intent/capabilities/workflow.js +458 -0
  79. package/dist/intent/capability.js +6 -0
  80. package/dist/intent/cli.js +91 -0
  81. package/dist/intent/compare.js +56 -0
  82. package/dist/intent/compiler.js +79 -0
  83. package/dist/intent/dsl.js +129 -0
  84. package/dist/intent/plan-file.js +63 -0
  85. package/dist/intent/readback.js +65 -0
  86. package/dist/intent/schema.js +158 -0
  87. package/dist/intent/validation.js +30 -0
  88. package/dist/intent/yaml.js +315 -0
  89. package/dist/json-column.js +68 -0
  90. package/dist/lanes/app-contract.js +95 -0
  91. package/dist/lanes/app-coverage.js +16 -0
  92. package/dist/lanes/app.js +174 -0
  93. package/dist/lanes/apply-changes.js +231 -0
  94. package/dist/lanes/b2-registration-contract.js +292 -0
  95. package/dist/lanes/b2-registration-coverage.js +48 -0
  96. package/dist/lanes/b2-registration.js +1187 -0
  97. package/dist/lanes/businessrule-contract.js +232 -0
  98. package/dist/lanes/businessrule-coverage.js +16 -0
  99. package/dist/lanes/businessrule.js +221 -0
  100. package/dist/lanes/contract-support.js +65 -0
  101. package/dist/lanes/coverage-declaration.js +9 -0
  102. package/dist/lanes/datarule-contract.js +155 -0
  103. package/dist/lanes/datarule-coverage.js +19 -0
  104. package/dist/lanes/datarule-protocol.js +308 -0
  105. package/dist/lanes/datarule.js +818 -0
  106. package/dist/lanes/dictionary-contract.js +78 -0
  107. package/dist/lanes/dictionary-coverage.js +24 -0
  108. package/dist/lanes/dictionary.js +235 -0
  109. package/dist/lanes/environment-contract.js +70 -0
  110. package/dist/lanes/environment-coverage.js +14 -0
  111. package/dist/lanes/environment.js +163 -0
  112. package/dist/lanes/field-contract.js +223 -0
  113. package/dist/lanes/field-coverage.js +27 -0
  114. package/dist/lanes/field.js +374 -0
  115. package/dist/lanes/form-contract.js +97 -0
  116. package/dist/lanes/form-coverage.js +16 -0
  117. package/dist/lanes/form.js +185 -0
  118. package/dist/lanes/lane-ids.js +34 -0
  119. package/dist/lanes/list-view-contract.js +175 -0
  120. package/dist/lanes/list-view-coverage.js +20 -0
  121. package/dist/lanes/list-view-shapes.js +1207 -0
  122. package/dist/lanes/list-view.js +578 -0
  123. package/dist/lanes/meta-contract.js +85 -0
  124. package/dist/lanes/meta.js +255 -0
  125. package/dist/lanes/model-contract.js +168 -0
  126. package/dist/lanes/model-coverage.js +20 -0
  127. package/dist/lanes/model.js +986 -0
  128. package/dist/lanes/online-js-contract.js +99 -0
  129. package/dist/lanes/online-js-coverage.js +28 -0
  130. package/dist/lanes/online-js.js +127 -0
  131. package/dist/lanes/report-contract.js +144 -0
  132. package/dist/lanes/report-coverage.js +21 -0
  133. package/dist/lanes/report.js +476 -0
  134. package/dist/lanes/rule-graph.js +1846 -0
  135. package/dist/lanes/rule-lifecycle-contract.js +92 -0
  136. package/dist/lanes/rule-lifecycle-coverage.js +20 -0
  137. package/dist/lanes/rule-lifecycle.js +176 -0
  138. package/dist/lanes/runtime-data-contract.js +264 -0
  139. package/dist/lanes/runtime-data-coverage.js +25 -0
  140. package/dist/lanes/runtime-data.js +1054 -0
  141. package/dist/lanes/workflow-contract.js +223 -0
  142. package/dist/lanes/workflow-coverage.js +40 -0
  143. package/dist/lanes/workflow.js +1813 -0
  144. package/dist/online-js-layout.js +58 -0
  145. package/dist/online-js-source.js +276 -0
  146. package/dist/package-tool.js +51 -0
  147. package/dist/package.js +73 -0
  148. package/dist/plan.js +73 -0
  149. package/dist/read.js +144 -0
  150. package/dist/redact.js +28 -0
  151. package/dist/rule-support.js +587 -0
  152. package/dist/runtime-support.js +134 -0
  153. package/dist/server.js +92 -0
  154. package/dist/session-manager.js +30 -0
  155. package/dist/session.js +149 -0
  156. package/dist/skills.js +112 -0
  157. package/dist/support.js +98 -0
  158. package/dist/tool-types.js +127 -0
  159. package/dist/tools.js +59 -0
  160. package/dist/usage-log.js +197 -0
  161. package/dist/wire.js +287 -0
  162. package/dist/workflow-support.js +99 -0
  163. package/dist/write-lease.js +26 -0
  164. package/dist/write-lock.js +109 -0
  165. package/dist/write.js +176 -0
  166. package/dist/zip.js +156 -0
  167. package/docs/tool-surface-map.md +42 -0
  168. package/package.json +71 -6
  169. package/skills/omc-acceptance-criteria.md +47 -0
  170. package/skills/omc-business-configuration.md +257 -0
  171. package/skills/omc-capabilities.md +197 -0
  172. package/skills/omc-capability-scouting.md +55 -0
  173. package/skills/omc-config-draft-review.md +171 -0
  174. package/skills/omc-five-piece-flow.md +35 -0
  175. package/skills/omc-glossary.md +86 -0
  176. package/skills/omc-refusals.md +110 -0
  177. package/skills/omc-requirement-analysis.md +161 -0
  178. package/skills/omc-requirement-vocabulary.md +51 -0
  179. package/skills/omc-start-here.md +82 -0
  180. package/skills/omc-tool-selection.md +119 -0
  181. package/skills/omc-write-hazards.md +87 -0
@@ -0,0 +1,1813 @@
1
+ import { z } from "zod";
2
+ import { ENDPOINTS } from "../endpoints.js";
3
+ import { capabilityArg, environmentArg } from "../args.js";
4
+ import { governedRead } from "../read.js";
5
+ import { ToolRefusalError } from "../tool-types.js";
6
+ import { writeTool } from "../write.js";
7
+ import { registerPackageTool } from "../package-tool.js";
8
+ import { writeSummary } from "../human-summary.js";
9
+ import { defineTool, ToolInputError } from "../tool-types.js";
10
+ import { wireCall, wireRows, WireError } from "../wire.js";
11
+ import { resolveBizObjectIdByInstance, searchWorkItems, wireString } from "../workflow-support.js";
12
+ import { WORKFLOW_EVENT_HANDLER_FIELDS, WORKFLOW_LANE_CONTRACT, workflowTemplateArg } from "./workflow-contract.js";
13
+ import { PLATFORM_TRIGGER_RULE_CODES } from "./rule-graph.js";
14
+ import { executeRuleEnableWrite } from "./rule-lifecycle.js";
15
+ const WORKFLOW_LANE = WORKFLOW_LANE_CONTRACT.lane;
16
+ const WORKFLOW_TOOLS = WORKFLOW_LANE_CONTRACT.tools;
17
+ const WORKFLOW_ACTIVITY_TYPES = new Set(["START", "END", "CONNECTION", "PARTICIPANT", "SUB_INSTANCE", "CIRCULATE", "SYSTEM_ACTIVITY", "AGENT_ACTIVITY"]);
18
+ const WORKFLOW_POINT = /^-?\d+(?:\.\d+)?,\s-?\d+(?:\.\d+)?$/;
19
+ /**
20
+ * Conditional branch lines (round7). `routes[]` maps to the platform `Rule`
21
+ * DTO (Rule.java:28-59), which carries a structured branch condition
22
+ * `ruleConditionGroup = List<List<RuleConditionItem>>` (outer OR / inner AND).
23
+ * ActivityContext.getNextFormulaActivities:82-99 only consults it when
24
+ * popupType == EXPRESSION; a rule with utilizeElse=true is the else/fallback
25
+ * line and is never a condition line. These enums mirror ConditionType.java and
26
+ * MatchType.java (shared 8.6.26); a typo silently no-ops on the platform, so the
27
+ * write gate refuses it instead.
28
+ */
29
+ const WORKFLOW_CONDITION_TYPES = new Set(["EQ", "NEQ", "GT", "LT", "GTEQ", "LTEQ", "CT", "NCT", "EP", "NEP", "OF", "NOF", "BETWEEN", "NOTBETWEEN", "CT_ALL", "NCT_ALL", "CT_ANY", "NCT_ANY", "EQ_ANY", "NEQ_ANY"]);
30
+ const WORKFLOW_MATCH_TYPES = new Set(["ALL", "ANY"]);
31
+ /**
32
+ * `RuleConditionItem` (RuleConditionItem.java, shared 8.6.26) has EXACTLY four
33
+ * fields: currentDataItem, bizPropertyType, conditionType, value. Any extra key
34
+ * makes the platform's workflow/update deserialization fail and answer a
35
+ * **misleading `401001 流程不存在!`** — not a key error (live 2026-09-25: adding
36
+ * the business-rule condition keys targetDataItem/ruleValueType to a workflow
37
+ * route condition poisoned the whole update). Fail closed on unknown keys before
38
+ * the wire so the agent gets the real fix instead of chasing a phantom workflow.
39
+ */
40
+ const WORKFLOW_RULE_CONDITION_ITEM_KEYS = new Set(["currentDataItem", "bizPropertyType", "conditionType", "value"]);
41
+ /**
42
+ * RuleConditionItem.bizPropertyType is a BizPropertyType whose fastjson
43
+ * deserializer (BizPropertyTypeDeserializer.deserialze) reads an INT index
44
+ * (`BizPropertyType.get(TypeUtils.castToInt(value))`) and whose serializer writes
45
+ * the same index. Sending the enum NAME ("NUMERICAL") makes castToInt coerce it
46
+ * to 0 (the wrong type) and the template conversion then fails with a MISLEADING
47
+ * `流程名称为空!` (live 101.96.203.196 / AI-Platform 1.1.22 — cost the sandbox
48
+ * agent the whole workflow write). Accept the name for ergonomics but emit the
49
+ * index on the wire.
50
+ */
51
+ const WORKFLOW_BIZ_PROPERTY_TYPE_INDEX = Object.freeze({
52
+ SHORT_TEXT: 0, LONG_TEXT: 1, NUMERICAL: 2, DATE: 3, LOGICAL: 4, SELECTION: 5,
53
+ ATTACHMENT: 6, COMMENT: 7, CHILD_TABLE: 8, WORK_SHEET: 9, ADDRESS: 10, MULT_WORK_SHEET: 11,
54
+ RADIO: 12, CHECKBOX: 13, DROPDOWN_BOX: 14, DROPDOWN_MULTI_BOX: 15, RELEVANCE_DATA: 16,
55
+ HYPER_LINK: 17, ID: 20, TIME: 28, STAFF_SELECTOR: 50, STAFF_MULTI_SELECTOR: 51,
56
+ DEPARTMENT_SELECTOR: 60, DEPARTMENT_MULTI_SELECTOR: 61, FORMULA: 100, OCR: 250,
57
+ ELECTRONIC_SIGN: 251, APPROVAL_INFO: 254,
58
+ });
59
+ const WORKFLOW_BIZ_PROPERTY_INDEX_SET = new Set(Object.values(WORKFLOW_BIZ_PROPERTY_TYPE_INDEX));
60
+ /** Resolve a caller-supplied bizPropertyType (index or enum name) to the wire index, or null if invalid. */
61
+ function resolveBizPropertyTypeIndex(value) {
62
+ if (typeof value === "number" && Number.isInteger(value))
63
+ return WORKFLOW_BIZ_PROPERTY_INDEX_SET.has(value) ? value : null;
64
+ if (typeof value === "string")
65
+ return WORKFLOW_BIZ_PROPERTY_TYPE_INDEX[value.trim().toUpperCase()] ?? null;
66
+ return null;
67
+ }
68
+ /** Rewrite each RuleConditionItem.bizPropertyType to the numeric index the platform deserializes. */
69
+ function normalizeRuleConditionGroup(groups) {
70
+ if (!Array.isArray(groups))
71
+ return groups;
72
+ return groups.map((group) => Array.isArray(group)
73
+ ? group.map((item) => {
74
+ const record = (item ?? {});
75
+ const index = resolveBizPropertyTypeIndex(record.bizPropertyType);
76
+ return index === null ? record : { ...record, bizPropertyType: index };
77
+ })
78
+ : group);
79
+ }
80
+ /** Route fields that describe the line's condition/behaviour (as opposed to geometry). */
81
+ const WORKFLOW_ROUTE_CONDITION_KEYS = ["text", "formula", "utilizeElse", "fixedPoint", "alternativeFlag", "childMatchType", "ruleConditionGroup"];
82
+ /**
83
+ * Fail closed on a malformed structured branch condition BEFORE any wire call —
84
+ * an unknown conditionType or childMatchType deserializes to null on the platform
85
+ * and the line silently never fires (the same class of silent no-op that bit
86
+ * checkType=2). Only caller-supplied `routes`/`rules` are checked.
87
+ */
88
+ function validateWorkflowRouteConditions(template, tool) {
89
+ const raw = template.routes ?? template.rules ?? template.edges;
90
+ if (!Array.isArray(raw))
91
+ return;
92
+ for (const [index, entry] of raw.entries()) {
93
+ const route = (entry ?? {});
94
+ // `null` is the platform's stored form for an absent optional — a caller
95
+ // legitimately round-tripping get_draft sends `text:null`/`formula:null`. Treat
96
+ // null like undefined, not as a wrong type.
97
+ for (const booleanKey of ["utilizeElse", "fixedPoint", "alternativeFlag"])
98
+ if (route[booleanKey] !== undefined && route[booleanKey] !== null && typeof route[booleanKey] !== "boolean")
99
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].${booleanKey} must be boolean` });
100
+ for (const stringKey of ["text", "formula"])
101
+ if (route[stringKey] !== undefined && route[stringKey] !== null && typeof route[stringKey] !== "string")
102
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].${stringKey} must be a string` });
103
+ if (route.childMatchType !== undefined && route.childMatchType !== null && !(typeof route.childMatchType === "string" && WORKFLOW_MATCH_TYPES.has(route.childMatchType)))
104
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].childMatchType must be ALL or ANY` });
105
+ if (route.ruleConditionGroup === undefined || route.ruleConditionGroup === null)
106
+ continue;
107
+ const groups = route.ruleConditionGroup;
108
+ if (!Array.isArray(groups) || groups.length === 0)
109
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].ruleConditionGroup must be a non-empty array of AND-groups (each an array of conditions)` });
110
+ for (const [groupIndex, group] of groups.entries()) {
111
+ if (!Array.isArray(group) || group.length === 0)
112
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].ruleConditionGroup[${groupIndex}] must be a non-empty array of RuleConditionItem` });
113
+ for (const [itemIndex, item] of group.entries()) {
114
+ const condition = (item ?? {});
115
+ if (typeof condition.currentDataItem !== "string" || condition.currentDataItem.trim() === "")
116
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].ruleConditionGroup[${groupIndex}][${itemIndex}].currentDataItem must be a non-empty field code (child fields use "childSchemaCode.fieldCode")` });
117
+ if (!(typeof condition.conditionType === "string" && WORKFLOW_CONDITION_TYPES.has(condition.conditionType)))
118
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].ruleConditionGroup[${groupIndex}][${itemIndex}].conditionType must be a ConditionType (${[...WORKFLOW_CONDITION_TYPES].join("/")})` });
119
+ if (condition.bizPropertyType !== undefined && condition.bizPropertyType !== null && resolveBizPropertyTypeIndex(condition.bizPropertyType) === null)
120
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].ruleConditionGroup[${groupIndex}][${itemIndex}].bizPropertyType must be a BizPropertyType index (NUMERICAL=2, DATE=3, SHORT_TEXT=0, STAFF_SELECTOR=50, …) or a known enum name` });
121
+ const unknownKeys = Object.keys(condition).filter((key) => !WORKFLOW_RULE_CONDITION_ITEM_KEYS.has(key));
122
+ if (unknownKeys.length > 0)
123
+ throw new ToolRefusalError({
124
+ status: "refused",
125
+ blocker: "workflow-route-condition-invalid",
126
+ message: `${tool}: routes[${index}].ruleConditionGroup[${groupIndex}][${itemIndex}] 含未知键 ${unknownKeys.join("/")}——RuleConditionItem 只接受 ${[...WORKFLOW_RULE_CONDITION_ITEM_KEYS].join("/")};未知键会让平台 workflow/update 返回**误导性的 401001 流程不存在!**(真机 2026-09-25)。业务规则条件组的 targetDataItem/ruleValueType 等键不能照抄到流程连线条件里。`,
127
+ });
128
+ }
129
+ }
130
+ }
131
+ }
132
+ /**
133
+ * Fail closed on an unknown bindRule handler BEFORE any wire call. The MCP
134
+ * transport validates the zod schema, but `omc call` (the first-class live
135
+ * driver) invokes the handler directly, so the semantic gate lives here.
136
+ */
137
+ function resolveEventHandlerField(handler) {
138
+ const key = handler ?? "end";
139
+ const field = WORKFLOW_EVENT_HANDLER_FIELDS[key];
140
+ if (!field) {
141
+ throw new ToolRefusalError({
142
+ status: "refused",
143
+ blocker: "workflow-invalid-handler",
144
+ handler,
145
+ message: `handler must be one of ${Object.keys(WORKFLOW_EVENT_HANDLER_FIELDS).join("|")} (omit for end)`,
146
+ });
147
+ }
148
+ return { key: key, field };
149
+ }
150
+ /**
151
+ * Live 8.6.26 fidelity — a REFUSED workflow write still answers HTTP 200 with
152
+ * `errcode 0`; the refusal rides `data` as a `ValidateMessage[]`
153
+ * ({errCode, errContent, errorLevel}; S29 live contract, publish refusal errmsg
154
+ * is 发布流程失败). Trusting `errcode` alone reports a phantom success: live, a
155
+ * PARTICIPANT node whose originate participant is `"admin"` (not the platform
156
+ * keyword `"{Originator}"`) answers `401035 填写申请单 ERROR` while errcode stays
157
+ * 0 — the tool said publish:ok and the flow never went live. Surface every
158
+ * ERROR-level message as a structured refusal so the caller can fix the
159
+ * template instead of believing the flow is running. WARN-level messages are
160
+ * returned as evidence but do not block (live: `{Creator}` answers 401062 WARN).
161
+ */
162
+ function workflowValidationErrors(data) {
163
+ if (!Array.isArray(data))
164
+ return [];
165
+ return data.filter((message) => message !== null && typeof message === "object" && message.errorLevel === "ERROR");
166
+ }
167
+ function workflowValidationWarnings(data) {
168
+ if (!Array.isArray(data))
169
+ return [];
170
+ return data.filter((message) => message !== null && typeof message === "object" && message.errorLevel !== "ERROR");
171
+ }
172
+ /** Refuse after a workflow write when the platform answered ValidateMessage[] ERRORs (errcode 0 notwithstanding). */
173
+ function assertWorkflowWriteAccepted(performed, tool, workflowCode) {
174
+ const errors = workflowValidationErrors(performed.data);
175
+ if (errors.length === 0)
176
+ return;
177
+ throw new ToolRefusalError({
178
+ status: "refused",
179
+ blocker: "workflow-publish-invalid",
180
+ workflowCode,
181
+ validationErrors: errors,
182
+ message: `${tool}: 平台以 ValidateMessage[] 拒绝(errcode 0 但不落库):${errors.map((error) => `${error.errCode} ${error.errContent}`).join(";")}。常见根因:审批(PARTICIPANT)节点的 sheetCode 为空(=模型默认表单槽位,通常等于 schemaCode;401026 WORKFLOW_ACTIVITY_NO_SHEET——工具已自动回填,仍报则检查端点是否漏),或新增节点未接入链路(END 无入边)。修正模板后重试,不要反复 publish`,
183
+ });
184
+ }
185
+ /**
186
+ * Ticket 77 — a START/END-only template cannot overwrite the platform's
187
+ * auto-generated full draft (PARTICIPANT 填写申请单/审批 nodes); the platform
188
+ * answers a stable 401001 流程不存在. Refuse pre-wire and point at the
189
+ * get_draft-based editing path (or workflow.bindRule for rule binding).
190
+ */
191
+ /**
192
+ * Caller activities may arrive as an array OR as an index-keyed object
193
+ * (`{"0":{…},"1":{…}}` — the shape the published workflow recipe teaches and
194
+ * the shape get_draft-adjacent callers reach for). Every downstream merge/lint
195
+ * step keys on an ARRAY; before this coercion an object-shaped `activities`
196
+ * was destructured away and silently DROPPED, so `workflow.update` answered
197
+ * `ok` while writing the unchanged draft — bench10/11: an agent's two-level
198
+ * approval template vanished and it could never land the second approval,
199
+ * burning 7 get_draft + 7 update round-trips. Coerce object → array in numeric
200
+ * key order; a non-array/non-object value is left to the element checks.
201
+ */
202
+ function callerActivities(template) {
203
+ const activities = template?.activities;
204
+ if (Array.isArray(activities))
205
+ return activities;
206
+ if (activities !== null && typeof activities === "object") {
207
+ return Object.keys(activities)
208
+ .sort((a, b) => Number(a) - Number(b))
209
+ .map((key) => activities[key]);
210
+ }
211
+ return undefined;
212
+ }
213
+ function assertTemplateNotMinimal(template, tool) {
214
+ // An absent template (publish the stored draft as-is) is the platform's own
215
+ // path and is out of the guard's scope — the bench golden evals drive it.
216
+ if (template === undefined || template === null)
217
+ return;
218
+ const activities = callerActivities(template);
219
+ const types = activities
220
+ ? activities
221
+ .map((activity) => (activity && typeof activity === "object" ? String(activity.type ?? activity.activityType ?? "") : ""))
222
+ .filter((type) => type.length > 0)
223
+ : [];
224
+ if (types.length > 0 && types.every((type) => type === "START" || type === "END")) {
225
+ throw new ToolRefusalError({
226
+ status: "refused",
227
+ blocker: "workflow-template-minimal",
228
+ message: `${tool} 收到仅含 START/END 的极简模板——平台为流程头自动生成含 PARTICIPANT 节点(填写申请单/审批)的完整草稿,极简模板无法覆盖,平台会稳定返回 401001 流程不存在。以 workflow.get_draft 返回的完整模板为基础修改后再 update/publish;只绑规则用 workflow.bindRule`,
229
+ });
230
+ }
231
+ }
232
+ /**
233
+ * Ticket 112 — dry-lint the caller template against the platform's update
234
+ * contract (8.6.26 decompiled source), zero wire:
235
+ * - `templateType:"BusinessProcess"` is NOT a WorkflowTemplateStatus member
236
+ * (WorkflowTemplateUtil.java:136 valueOf; the converter swallows the
237
+ * exception into a partial model and the save chain then fails with errcode
238
+ * 50000) — omit it and the draft's value rides along.
239
+ * - every activities element needs a type (platform keys on activityType;
240
+ * ActivityType.valueOf("null") throws the same swallowed way).
241
+ */
242
+ function lintWorkflowTemplate(template, tool) {
243
+ if (template === undefined || template === null)
244
+ return;
245
+ if (template.templateType === "BusinessProcess")
246
+ throw new ToolRefusalError({
247
+ status: "refused",
248
+ blocker: "workflow-template-type-invalid",
249
+ message: `${tool}: templateType "BusinessProcess" 不是 WorkflowTemplateStatus 枚举成员(WorkflowTemplateUtil.java:136 valueOf——转换异常被吞成残缺模型后保存链 50000)。省略 templateType,由 get_draft 草稿值回显`,
250
+ });
251
+ const activities = callerActivities(template);
252
+ if (activities) {
253
+ for (const activity of activities) {
254
+ const record = activity;
255
+ const type = record != null && typeof record === "object" ? (record.type ?? record.activityType) : undefined;
256
+ if (type === undefined || type === null || String(type).trim() === "")
257
+ throw new ToolRefusalError({
258
+ status: "refused",
259
+ blocker: "workflow-activity-type-missing",
260
+ message: `${tool}: activities 元素缺少 type/activityType(平台 ActivityType.valueOf 对 "null" 抛异常被吞成残缺模型 → 50000);每个活动必须带类型`,
261
+ });
262
+ }
263
+ }
264
+ }
265
+ /** Caller-side Activity aliases (input + workflowSummary spelling) that must never ride the wire. */
266
+ const ACTIVITY_INPUT_ALIAS_KEYS = ["id", "type", "name", "code", "index"];
267
+ /**
268
+ * Ticket 112 — normalize the caller template onto the platform DTO:
269
+ * - `routes[]` is NOT a WorkflowTemplateModel field (WorkflowTemplateModel
270
+ * .java:51-66 declares List<Rule> rules; Rule.java:40-57
271
+ * preActivityCode/postActivityCode). Caller routes are mapped to rules
272
+ * elements — with derived string `points` (the workflow designer canvas
273
+ * keys on them exactly like the rule graph does) — and the routes key
274
+ * never rides the wire.
275
+ * - caller activities may use the repo shorthand {id,type,name,participant};
276
+ * the platform wire keys are activityCode/activityType/activityName
277
+ * (WorkflowTemplateController update @ApiImplicitParam example;
278
+ * ActivityType.valueOf("null") makes the converter fail → 401001). Each
279
+ * caller activity is merged onto the matching DRAFT activity so the
280
+ * platform-required subfaces (todoDataItems, permittedAction,
281
+ * preCondition, geometry) echo instead of holing the model.
282
+ */
283
+ function mapCallerActivities(callerActivities, draftActivities) {
284
+ const draftByCode = new Map(draftActivities.map((activity) => [String(activity.activityCode ?? ""), activity]));
285
+ const draftByType = (type) => draftActivities.find((activity) => String(activity.activityType ?? "") === type);
286
+ return callerActivities.map((entry) => {
287
+ const record = (entry ?? {});
288
+ const code = activityCodeOf(record);
289
+ const rawType = String(record.activityType ?? record.type ?? "");
290
+ const type = rawType === "APPROVAL" ? "PARTICIPANT" : rawType;
291
+ const base = draftByCode.get(code) ??
292
+ (type === "START" ? draftByType("START") : type === "END" ? draftByType("END") : type === "PARTICIPANT" ? draftByType("PARTICIPANT") : undefined) ??
293
+ {};
294
+ // Drop the input aliases AND the readback-only keys (`code`/`index` come
295
+ // from workflowSummary). Stray keys on an Activity/Rule make the platform
296
+ // answer a misleading 401001; only canonical keys may ride the wire. The
297
+ // participant is normalized as part of the record so the trailing spread
298
+ // cannot re-introduce a shorthand the platform cannot resolve.
299
+ const wireRecord = {
300
+ ...Object.fromEntries(Object.entries(record).filter(([key]) => !ACTIVITY_INPUT_ALIAS_KEYS.includes(key))),
301
+ ...(record.participant !== undefined ? { participant: normalizeParticipantForWire(record.participant) } : {}),
302
+ };
303
+ return {
304
+ ...base,
305
+ activityCode: code,
306
+ ...(type ? { activityType: type } : {}),
307
+ ...(record.name !== undefined ? { activityName: record.name } : {}),
308
+ ...wireRecord,
309
+ };
310
+ });
311
+ }
312
+ function validateCallerActivities(template, tool) {
313
+ const activities = callerActivities(template);
314
+ if (!activities)
315
+ return;
316
+ const codes = new Set();
317
+ for (const [index, raw] of activities.entries()) {
318
+ const activity = raw;
319
+ const code = String(activity?.activityCode ?? activity?.id ?? activity?.code ?? "").trim();
320
+ const rawType = String(activity?.activityType ?? activity?.type ?? "").trim();
321
+ const type = rawType === "APPROVAL" ? "PARTICIPANT" : rawType;
322
+ if (!code)
323
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-code-invalid", message: `${tool}: activities[${index}] requires a non-empty activityCode/id` });
324
+ if (codes.has(code))
325
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-code-duplicate", message: `${tool}: duplicate activityCode ${code}` });
326
+ if (!rawType)
327
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-activity-type-missing", message: `${tool}: activities[${index}] requires type/activityType` });
328
+ if (!WORKFLOW_ACTIVITY_TYPES.has(type))
329
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-type-invalid", message: `${tool}: activities[${index}] has an illegal ActivityType` });
330
+ codes.add(code);
331
+ }
332
+ }
333
+ /**
334
+ * Workflow canonical node layout — the same lesson as the rule graph
335
+ * (CFG-26), freshly observed live 2026-09-17: mapCallerActivities merges an
336
+ * activity onto the draft row by code, else by TYPE. A caller who names TWO
337
+ * PARTICIPANT nodes has neither code in the draft, so BOTH inherit the single
338
+ * draft PARTICIPANT's box (x=327, y=116) and overlap — the designer then draws
339
+ * the fill→approve edge as a horizontal stub and the chain is unreadable. When
340
+ * the merged activities lack geometry or collide, lay the chain out in the
341
+ * platform's single column (x=327, 158×40, 80px apart) and re-derive the rule
342
+ * points. A graph the caller laid out deliberately (no missing box, no
343
+ * overlap) is preserved verbatim.
344
+ */
345
+ function linearWorkflowOrder(activities, rules) {
346
+ const codes = activities.map((activity) => String(activity.activityCode ?? ""));
347
+ if (codes.some((code) => !code) || new Set(codes).size !== codes.length)
348
+ return [];
349
+ const outgoing = new Map();
350
+ for (const rule of rules) {
351
+ const pre = String(rule.preActivityCode ?? "");
352
+ const post = String(rule.postActivityCode ?? "");
353
+ if (!codes.includes(pre) || !codes.includes(post))
354
+ continue;
355
+ outgoing.set(pre, [...(outgoing.get(pre) ?? []), post]);
356
+ }
357
+ const starts = activities.filter((activity) => activity.activityType === "START").map((activity) => String(activity.activityCode));
358
+ if (starts.length !== 1 || [...outgoing.values()].some((targets) => targets.length > 1))
359
+ return [];
360
+ const order = [];
361
+ let cursor = starts[0];
362
+ while (cursor !== undefined && !order.includes(cursor)) {
363
+ order.push(cursor);
364
+ cursor = outgoing.get(cursor)?.[0];
365
+ }
366
+ return order.length === codes.length ? order : [];
367
+ }
368
+ function canonicalizeWorkflowLayout(activities, rules) {
369
+ const numberOr = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
370
+ const boxOf = (activity) => ({
371
+ x: numberOr(activity.x, Number.NaN),
372
+ y: numberOr(activity.y, Number.NaN),
373
+ width: numberOr(activity.width, 158),
374
+ height: numberOr(activity.height, 40),
375
+ });
376
+ const boxes = activities.map(boxOf);
377
+ const missing = boxes.some((box) => Number.isNaN(box.x) || Number.isNaN(box.y));
378
+ const overlaps = boxes.some((a, ai) => boxes.some((b, bi) => bi > ai && !Number.isNaN(a.x) && !Number.isNaN(b.x) && a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height));
379
+ if (!missing && !overlaps)
380
+ return { activities, changed: false };
381
+ const order = linearWorkflowOrder(activities, rules);
382
+ const position = (code, index) => (order.length > 0 ? order.indexOf(code) : index);
383
+ return {
384
+ changed: true,
385
+ activities: activities.map((activity, index) => ({
386
+ ...activity,
387
+ x: 327,
388
+ y: 36 + 80 * position(String(activity.activityCode ?? ""), index),
389
+ width: 158,
390
+ height: 40,
391
+ })),
392
+ };
393
+ }
394
+ /** Ticket 112 — workflow rules carry designer-canvas `points` (same lesson as the rule graph): derive from the activity boxes. */
395
+ function deriveRulePoints(pre, post, activities) {
396
+ const numberOr = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
397
+ const boxOf = (code, index) => {
398
+ const activity = activities.find((candidate) => String(candidate.activityCode ?? "") === code) ?? activities[index];
399
+ return {
400
+ x: numberOr(activity?.x, 327),
401
+ y: numberOr(activity?.y, 36 + 80 * index),
402
+ width: numberOr(activity?.width, 158),
403
+ height: numberOr(activity?.height, 40),
404
+ };
405
+ };
406
+ const preIndex = Math.max(0, activities.findIndex((candidate) => String(candidate.activityCode ?? "") === pre));
407
+ const postIndex = Math.max(0, activities.findIndex((candidate) => String(candidate.activityCode ?? "") === post));
408
+ const s = boxOf(pre, preIndex);
409
+ const t = boxOf(post, postIndex);
410
+ const scx = s.x + s.width / 2;
411
+ const tcx = t.x + t.width / 2;
412
+ if (t.y > s.y)
413
+ return [`${scx}, ${s.y + s.height}`, `${tcx}, ${t.y}`];
414
+ if (t.y < s.y)
415
+ return [`${scx}, ${s.y}`, `${tcx}, ${t.y + t.height}`];
416
+ if (t.x >= s.x)
417
+ return [`${s.x + s.width}, ${s.y + s.height / 2}`, `${t.x}, ${t.y + t.height / 2}`];
418
+ return [`${s.x}, ${s.y + s.height / 2}`, `${t.x + t.width}, ${t.y + t.height / 2}`];
419
+ }
420
+ function mapCallerRules(callerRoutes, draftRules, activities) {
421
+ const baseRule = draftRules[0] ?? {};
422
+ return callerRoutes.map((entry) => {
423
+ const record = (entry ?? {});
424
+ const pre = String(record.preActivityCode ?? record.preNode ?? record.pre ?? record.from ?? "");
425
+ const post = String(record.postActivityCode ?? record.postNode ?? record.post ?? record.to ?? "");
426
+ const points = Array.isArray(record.points) && record.points.length > 0 ? record.points : deriveRulePoints(pre, post, activities);
427
+ const mapped = {
428
+ ...baseRule,
429
+ ...Object.fromEntries(Object.entries(record).filter(([key]) => !["preNode", "postNode", "pre", "post", "from", "to"].includes(key))),
430
+ preActivityCode: pre,
431
+ postActivityCode: post,
432
+ points,
433
+ };
434
+ // A structured branch line (round7) only fires when popupType is EXPRESSION
435
+ // and it is NOT the else/fallback line (ActivityContext
436
+ // .getNextFormulaActivities:82-99). The platform defaults are popupType
437
+ // FUNCTION (Rule.java:49) and utilizeElse true (Rule.java:37) — either would
438
+ // silently turn a supplied ruleConditionGroup into a no-op, and both ride in
439
+ // via the draft baseRule. So force them unless the caller set them itself.
440
+ if (record.ruleConditionGroup !== undefined) {
441
+ if (record.popupType === undefined)
442
+ mapped.popupType = "EXPRESSION";
443
+ if (record.utilizeElse === undefined)
444
+ mapped.utilizeElse = false;
445
+ // The platform deserializes BizPropertyType from an INT index; normalize a
446
+ // caller's enum name before the wire (see WORKFLOW_BIZ_PROPERTY_TYPE_INDEX).
447
+ mapped.ruleConditionGroup = normalizeRuleConditionGroup(record.ruleConditionGroup);
448
+ }
449
+ return mapped;
450
+ });
451
+ }
452
+ /**
453
+ * Participant references must be platform-resolvable. Real published workflows
454
+ * (2026-09-23 production observation) use exactly three forms:
455
+ * (a) a keyword — `{Originator}`;
456
+ * (b) a JSON array of participant objects —
457
+ * `[{"code","corpId","id","name","type"}]` (type 2 = role, 3 = user),
458
+ * joined with `+` for several;
459
+ * (c) a function — `FindUserByRole({Originator.OU},"<roleCode>","random")`.
460
+ * Free text (a bare role name like `部门负责人`) publishes but resolves to
461
+ * nobody and creates no workitem, so warn — the caller must resolve names via
462
+ * `org.role.list` / `org.department.list` first. Advisory (never blocks): the
463
+ * platform accepts the save, so this is a readback lint, not a write gate.
464
+ */
465
+ function participantReferenceWarnings(template) {
466
+ const activities = Array.isArray(template.activities) ? template.activities : [];
467
+ const warnings = [];
468
+ for (const activity of activities) {
469
+ if (String(activity.activityType ?? "") !== "PARTICIPANT")
470
+ continue;
471
+ const raw = activity.participant;
472
+ if (raw === undefined || raw === null || String(raw).trim().length === 0)
473
+ continue; // platform may fill later
474
+ const participant = String(raw).trim();
475
+ if (isPlatformParticipantReference(participant))
476
+ continue;
477
+ warnings.push({
478
+ activityCode: String(activity.activityCode ?? ""),
479
+ participant,
480
+ warning: `参与者「${participant}」不是平台可解析的引用(自由文本解析不到人,不生成待办)。真实语法:关键字 {Originator};参与者对象数组 [{"code":"<角色code>","corpId":"<corpId|other>","id":"<roleId>","name":"<角色名>","type":2}](type 2=角色,3=用户,多个用 + 连接);或函数 FindUserByRole({Originator.OU},"<roleCode>","random")。先用 org.role.list / org.department.list / org.user.search 解析 code/id。`,
481
+ });
482
+ }
483
+ return warnings;
484
+ }
485
+ function isPlatformParticipantReference(participant) {
486
+ if (/^\{[^}]+\}$/.test(participant))
487
+ return true; // {Originator} keyword
488
+ if (/^[A-Za-z_$][\w$]*\s*\(/.test(participant))
489
+ return true; // FindUserByRole(...)
490
+ if (participant.startsWith("[") || participant.startsWith("+")) {
491
+ // one or more `[...]` segments joined by +
492
+ const segments = participant.split("+").map((segment) => segment.trim());
493
+ return segments.every((segment) => {
494
+ if (segment.length === 0)
495
+ return true;
496
+ try {
497
+ const parsed = JSON.parse(segment);
498
+ return Array.isArray(parsed) && parsed.every((entry) => entry !== null && typeof entry === "object" && ("id" in entry || "code" in entry));
499
+ }
500
+ catch {
501
+ return false;
502
+ }
503
+ });
504
+ }
505
+ return false;
506
+ }
507
+ /**
508
+ * Normalize a participant reference to a platform-resolvable form BEFORE it rides
509
+ * the wire. Live 2026-09-28: an intent that declares `participant: {type: Originator}`
510
+ * (or a bare `participant: Originator`) is stored as the JSON string
511
+ * `"{\"type\":\"Originator\"}"`, and `workflow.publish` refuses with
512
+ * `401035 填写申请单` — the originate participant never resolves, so the whole
513
+ * workflow will not publish. The platform understands three forms (see
514
+ * participantReferenceWarnings); this maps the natural shorthands onto them:
515
+ * - a lone originator (bare `Originator`, or `{type/participantType: "Originator"}`
516
+ * with only display keys) → the keyword `"{Originator}"`
517
+ * - a role/user object `{code|id: …}` → a one-element JSON array `[obj]`
518
+ * - an array → JSON string; a string is left verbatim (already a valid form)
519
+ * Existing `"{Originator}"`/function/role-array strings pass through unchanged.
520
+ */
521
+ export function normalizeParticipantForWire(participant) {
522
+ if (participant === undefined || participant === null)
523
+ return participant;
524
+ if (typeof participant === "string") {
525
+ return /^originator$/i.test(participant.trim()) ? "{Originator}" : participant;
526
+ }
527
+ if (Array.isArray(participant))
528
+ return participant.length === 0 ? "" : JSON.stringify(participant);
529
+ if (typeof participant === "object") {
530
+ const record = participant;
531
+ const keys = Object.keys(record);
532
+ const typeValue = record.type ?? record.participantType;
533
+ const displayOnly = keys.every((key) => ["type", "participantType", "name", "i18nMessage", "label", "displayName"].includes(key));
534
+ if (displayOnly && typeof typeValue === "string" && /^originator$/i.test(typeValue.trim()))
535
+ return "{Originator}";
536
+ // A role/user descriptor must be a JSON ARRAY string; a bare object fails
537
+ // resolution too.
538
+ if ("code" in record || "id" in record)
539
+ return JSON.stringify([record]);
540
+ return JSON.stringify(record);
541
+ }
542
+ return participant;
543
+ }
544
+ function workflowRenderCompat(template) {
545
+ const activities = Array.isArray(template.activities) ? template.activities : [];
546
+ const rules = Array.isArray(template.rules) ? template.rules : [];
547
+ const activityCodes = new Set(activities.map((activity) => String(activity.activityCode ?? "")));
548
+ const danglingViolations = rules.flatMap((rule, index) => {
549
+ const missing = ["preActivityCode", "postActivityCode"].filter((key) => !activityCodes.has(String(rule[key] ?? "")));
550
+ return missing.length > 0 ? [{ ruleIndex: index, missing }] : [];
551
+ });
552
+ // 2026-09-25 cost pass: geometry + route points are deterministic for the
553
+ // canonical single column (x=327, 158×40, 80px apart). Echoing all of them on
554
+ // every activity/edge was ~880 B per workflow write. Only a DEVIATION from the
555
+ // canonical seat/edge is meaningful (a caller-placed layout, or a collision the
556
+ // canonicalizer fixed), so only deviations are echoed; their absence proves the
557
+ // graph is canonical. This keeps the render-compat capability (ticket 109:
558
+ // catch a broken/off-column layout) at a fraction of the bytes.
559
+ const numberOr = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
560
+ const order = linearWorkflowOrder(activities, rules);
561
+ const seat = (activity, index) => ({
562
+ x: 327,
563
+ y: 36 + 80 * (order.length > 0 ? order.indexOf(String(activity.activityCode ?? "")) : index),
564
+ width: 158,
565
+ height: 40,
566
+ });
567
+ const canonicalGeometry = (activity, index) => {
568
+ const expected = seat(activity, index);
569
+ return numberOr(activity.x, Number.NaN) === expected.x && numberOr(activity.y, Number.NaN) === expected.y && numberOr(activity.width, expected.width) === expected.width && numberOr(activity.height, expected.height) === expected.height;
570
+ };
571
+ return {
572
+ activities: activities.map((activity, index) => {
573
+ const geometry = { x: activity.x, y: activity.y, width: activity.width, height: activity.height };
574
+ return { activityCode: String(activity.activityCode), activityType: String(activity.activityType), ...(canonicalGeometry(activity, index) ? {} : { geometry }) };
575
+ }),
576
+ rules: rules.map((rule) => {
577
+ const pre = String(rule.preActivityCode);
578
+ const post = String(rule.postActivityCode);
579
+ const stored = Array.isArray(rule.points) ? rule.points : [];
580
+ const derived = deriveRulePoints(pre, post, activities);
581
+ const canonicalPoints = stored.length === derived.length && stored.every((point, index) => point === derived[index]);
582
+ return { preActivityCode: pre, postActivityCode: post, ...(canonicalPoints ? {} : { points: rule.points }), ...workflowRouteConditionView(rule) };
583
+ }),
584
+ startEnd: {
585
+ start: activities.filter((activity) => activity.activityType === "START").map((activity) => String(activity.activityCode)),
586
+ end: activities.filter((activity) => activity.activityType === "END").map((activity) => String(activity.activityCode)),
587
+ },
588
+ danglingViolations,
589
+ };
590
+ }
591
+ /**
592
+ * Round7 — the branch-relevant fields of a `Rule`, for readback. Without this
593
+ * the route condition is invisible: an agent could write a branch and never
594
+ * confirm which edge carries which condition.
595
+ *
596
+ * 2026-09-25 cost pass: the platform stores every optional rule key (`text`,
597
+ * `formula`, `childMatchType`, `ruleConditionGroup` as null; `utilizeElse`,
598
+ * `fixedPoint`, `alternativeFlag` as false; `popupType` as "FUNCTION"). Echoing
599
+ * those defaults repeated ~8 keys on EVERY edge of every workflow read (a 4-edge
600
+ * workflow carried ~890 B of edges, mostly defaults). Only meaningful values
601
+ * survive here; a default/absent key stays out. `full:true` still returns the
602
+ * raw stored row.
603
+ */
604
+ function workflowRouteConditionView(rule) {
605
+ const view = {};
606
+ for (const key of ["text", "formula", "childMatchType", "ruleConditionGroup"]) {
607
+ const value = rule[key];
608
+ if (value !== undefined && value !== null)
609
+ view[key] = value;
610
+ }
611
+ for (const key of ["utilizeElse", "fixedPoint", "alternativeFlag"]) {
612
+ if (rule[key] === true)
613
+ view[key] = true;
614
+ }
615
+ if (rule.popupType !== undefined && rule.popupType !== null && rule.popupType !== "FUNCTION")
616
+ view.popupType = rule.popupType;
617
+ return view;
618
+ }
619
+ function validateWorkflowRender(template, tool) {
620
+ const activities = template.activities;
621
+ if (!Array.isArray(activities))
622
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activities-invalid", message: `${tool}: activities must be an array` });
623
+ const codes = new Set();
624
+ for (const [index, raw] of activities.entries()) {
625
+ const activity = raw;
626
+ const code = String(activity?.activityCode ?? "").trim();
627
+ const type = String(activity?.activityType ?? "").trim();
628
+ if (!code)
629
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-code-invalid", message: `${tool}: activities[${index}].activityCode must be non-empty` });
630
+ if (codes.has(code))
631
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-code-duplicate", message: `${tool}: duplicate activityCode ${code}` });
632
+ if (!WORKFLOW_ACTIVITY_TYPES.has(type))
633
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-type-invalid", message: `${tool}: activities[${index}].activityType is not an 8.6.26 ActivityType` });
634
+ codes.add(code);
635
+ }
636
+ const rules = template.rules;
637
+ if (!Array.isArray(rules))
638
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-rules-invalid", message: `${tool}: rules must be an array` });
639
+ for (const [index, raw] of rules.entries()) {
640
+ const rule = raw;
641
+ const pre = String(rule?.preActivityCode ?? "").trim();
642
+ const post = String(rule?.postActivityCode ?? "").trim();
643
+ if (!pre || !post || !codes.has(pre) || !codes.has(post))
644
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-dangling-rule", message: `${tool}: rules[${index}] references an unknown activity` });
645
+ const points = rule?.points;
646
+ if (!Array.isArray(points) || points.length === 0 || points.some((point) => typeof point !== "string" || !WORKFLOW_POINT.test(point)))
647
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-rules-invalid", message: `${tool}: rules[${index}].points must be a non-empty string[] of "x, y" coordinates` });
648
+ }
649
+ }
650
+ /**
651
+ * The draft rules remapped by index cannot describe an activity the caller
652
+ * ADDED (an extra approval node): the remapped edges stop at the old chain, an
653
+ * extra END sits with no inbound edge, and the platform publish validator
654
+ * answers 401026/401027/401017 on 结束 (bench12, live). When the remapped rules
655
+ * are not a clean linear chain over the mapped activities, rebuild the chain in
656
+ * the caller's array order — the recipe contract is "array order = approval
657
+ * chain order" — reusing any edge whose endpoints survived. A draft whose
658
+ * (possibly branching) graph is untouched passes the chain check verbatim.
659
+ */
660
+ function completeWorkflowChain(rules, activities) {
661
+ const codes = activities.map((activity) => String(activity.activityCode ?? "")).filter((code) => code.length > 0);
662
+ if (codes.length < 2)
663
+ return rules;
664
+ const typeOf = (code) => String(activities.find((activity) => String(activity.activityCode ?? "") === code)?.activityType ?? "");
665
+ const codeSet = new Set(codes);
666
+ const inbound = new Map();
667
+ const outbound = new Map();
668
+ let valid = true;
669
+ for (const rule of rules) {
670
+ const pre = String(rule.preActivityCode ?? "");
671
+ const post = String(rule.postActivityCode ?? "");
672
+ if (!codeSet.has(pre) || !codeSet.has(post)) {
673
+ valid = false;
674
+ continue;
675
+ }
676
+ outbound.set(pre, (outbound.get(pre) ?? 0) + 1);
677
+ inbound.set(post, (inbound.get(post) ?? 0) + 1);
678
+ }
679
+ for (const code of codes) {
680
+ const type = typeOf(code);
681
+ if (type !== "START" && (inbound.get(code) ?? 0) !== 1)
682
+ valid = false;
683
+ if (type !== "END" && (outbound.get(code) ?? 0) !== 1)
684
+ valid = false;
685
+ }
686
+ if (valid)
687
+ return rules;
688
+ const byEdge = new Map(rules.map((rule) => [`${rule.preActivityCode}->${rule.postActivityCode}`, rule]));
689
+ return codes.slice(0, -1).map((code, index) => byEdge.get(`${code}->${codes[index + 1]}`) ?? { preActivityCode: code, postActivityCode: codes[index + 1] });
690
+ }
691
+ /** Activity type resolution shared by the merge + code reconcile (APPROVAL is a legacy alias). */
692
+ function activityTypeOf(record) {
693
+ const rawType = String(record.activityType ?? record.type ?? "");
694
+ return rawType === "APPROVAL" ? "PARTICIPANT" : rawType;
695
+ }
696
+ /** Display name the caller asked for, in any accepted spelling. */
697
+ function activityNameOf(record) {
698
+ const value = record.activityName ?? record.i18nMessage ?? record.name;
699
+ if (typeof value === "string" && value.length > 0)
700
+ return value;
701
+ // The participant shorthand `{type: Originator, name: 部门负责人}` carries the
702
+ // intended NODE label inside `participant.name`. The platform only resolves
703
+ // the participant keyword and defaults the activity name to 填写申请单, so
704
+ // without this the declared node name is silently lost (bench62: both approval
705
+ // nodes read back as 填写申请单). Promote it when the participant is the lone
706
+ // originator shorthand (the role-descriptor form keeps its own semantics).
707
+ const participant = record.participant;
708
+ if (participant && typeof participant === "object" && !Array.isArray(participant)) {
709
+ const descriptor = participant;
710
+ const typeValue = descriptor.type ?? descriptor.participantType;
711
+ if (typeof typeValue === "string" && /^originator$/i.test(typeValue.trim()) && typeof descriptor.name === "string" && descriptor.name.trim().length > 0)
712
+ return descriptor.name;
713
+ }
714
+ return undefined;
715
+ }
716
+ /**
717
+ * Activity code in any accepted spelling. `code` is what `workflowSummary`
718
+ * (get_draft/get_published compact) emits, so `get_draft → edit → update` — the
719
+ * documented "read back then send the delta" flow — round-trips: an activity the
720
+ * readback handed you is directly re-sendable.
721
+ */
722
+ function activityCodeOf(record) {
723
+ return String(record.activityCode ?? record.id ?? record.code ?? "");
724
+ }
725
+ /**
726
+ * Live 2026-09-23: the platform's workflow/update CANNOT RENAME an existing
727
+ * activityCode — the stored name and type win. A caller list that reuses the
728
+ * auto-draft's `ActivityN` codes therefore corrupts: the caller's
729
+ * `Activity3` PARTICIPANT keeps the draft's name ("审批"), and a caller's
730
+ * `Activity4` PARTICIPANT merges onto the draft's END node (name "结束",
731
+ * type END) — reproduced on bench12/13/15, where the second approval node
732
+ * showed as "结束". Only a FRESH code receives the caller's name/type.
733
+ *
734
+ * Reconcile the caller list against the draft: keep a code when the caller
735
+ * re-sends the same type + name (idempotent re-update), otherwise mint a
736
+ * collision-free code so the change actually lands. Returns the rewritten
737
+ * activities plus the original→final code map for route remapping.
738
+ */
739
+ function reconcileCallerActivityCodes(callerActivities, draftActivities) {
740
+ const draftByCode = new Map(draftActivities.map((activity) => [String(activity.activityCode ?? ""), activity]));
741
+ const used = new Set(draftActivities.map((activity) => String(activity.activityCode ?? "")));
742
+ const codeMap = new Map();
743
+ const activities = callerActivities.map((record) => {
744
+ const original = activityCodeOf(record);
745
+ const type = activityTypeOf(record);
746
+ const name = activityNameOf(record);
747
+ const draft = draftByCode.get(original);
748
+ let code = original;
749
+ if (draft !== undefined) {
750
+ const draftType = String(draft.activityType ?? "");
751
+ const draftName = activityNameOf(draft);
752
+ const changed = draftType !== type || (name !== undefined && draftName !== undefined && name !== draftName);
753
+ if (changed) {
754
+ let suffix = 2;
755
+ while (used.has(`${original}_${suffix}`))
756
+ suffix += 1;
757
+ code = `${original}_${suffix}`;
758
+ }
759
+ }
760
+ used.add(code);
761
+ codeMap.set(original, code);
762
+ return { ...record, activityCode: code, ...(name !== undefined ? { activityName: name, i18nMessage: name } : {}) };
763
+ });
764
+ return { activities, codeMap };
765
+ }
766
+ /**
767
+ * Context-cost (round 15): a caller template that declares activities but NOT
768
+ * both boundaries is necessarily a partial edit — a workflow without START+END
769
+ * cannot stand alone on the platform (create always mints them). Treating it as
770
+ * a full replace dropped the draft's boundary nodes and answered a misleading
771
+ * `50000 服务异常` (bench43: a targeted `propertyPermissions` update on two
772
+ * PARTICIPANT nodes lost start/end, and the agent's recovery was to read the
773
+ * whole 43.7 KB body back and post it verbatim — 93 KB, 38% of the task).
774
+ * Route such a template through the delta merge instead, so the caller can send
775
+ * only the node(s) it touches. A template that DOES carry both boundaries keeps
776
+ * replace semantics (an explicit full-graph rewrite).
777
+ */
778
+ function activitiesLackBoundaries(template) {
779
+ const activities = callerActivities(template);
780
+ if (!activities || activities.length === 0)
781
+ return false;
782
+ const types = activities.map((raw) => {
783
+ const record = raw;
784
+ return record != null && typeof record === "object" ? String(record.activityType ?? record.type ?? "").trim().toUpperCase() : "";
785
+ });
786
+ return !(types.includes("START") && types.includes("END"));
787
+ }
788
+ /**
789
+ * Deep-merge a caller activity row onto its draft row, with `propertyPermissions`
790
+ * (and each entry's `subPropertyPermissions`) merged BY `propertyCode` rather
791
+ * than replaced. A targeted field-permission edit sends one or two permission
792
+ * entries; a raw array replace would silently drop every field the caller did
793
+ * not re-send. Only arrays deep-merge; an explicit non-array (e.g. null) is
794
+ * taken as the caller's intent to replace.
795
+ */
796
+ function mergePermissionsByCode(base, caller) {
797
+ if (!Array.isArray(base) || !Array.isArray(caller))
798
+ return caller;
799
+ const byCode = new Map();
800
+ for (const entry of base) {
801
+ if (entry && typeof entry === "object") {
802
+ const row = entry;
803
+ byCode.set(String(row.propertyCode ?? ""), row);
804
+ }
805
+ }
806
+ for (const entry of caller) {
807
+ if (!entry || typeof entry !== "object")
808
+ continue;
809
+ const row = entry;
810
+ const key = String(row.propertyCode ?? "");
811
+ const prev = byCode.get(key);
812
+ byCode.set(key, prev
813
+ ? {
814
+ ...prev,
815
+ ...row,
816
+ ...(row.subPropertyPermissions !== undefined ? { subPropertyPermissions: mergePermissionsByCode(prev.subPropertyPermissions, row.subPropertyPermissions) } : {}),
817
+ }
818
+ : row);
819
+ }
820
+ return [...byCode.values()];
821
+ }
822
+ function deepMergeActivityRow(base, caller) {
823
+ const merged = { ...base, ...caller };
824
+ if (Array.isArray(caller.propertyPermissions))
825
+ merged.propertyPermissions = mergePermissionsByCode(base.propertyPermissions, caller.propertyPermissions);
826
+ return merged;
827
+ }
828
+ /**
829
+ * Incremental (`merge:true`) workflow edit: the caller sends ONLY the activities/
830
+ * routes it adds or changes; the draft is the base. Draft activities survive
831
+ * unless overridden by `activityCode`, caller-only codes append (usually before
832
+ * END via `completeWorkflowChain`), and route edges union (caller wins on the
833
+ * same edge). This is the "read back, then send the delta" path — without it a
834
+ * subset silently REPLACED the whole activity list and dropped nodes.
835
+ */
836
+ function mergeCallerDelta(template, draft) {
837
+ const { routes: routesRaw, activities: _raw, edges: edgesRaw, ...rest } = template;
838
+ const routes = routesRaw ?? edgesRaw;
839
+ const draftActivities = Array.isArray(draft.activities) ? draft.activities : [];
840
+ const draftRules = Array.isArray(draft.rules) ? draft.rules : [];
841
+ const caller = (callerActivities(template) ?? []).map((raw) => {
842
+ const type = activityTypeOf(raw);
843
+ const name = activityNameOf(raw);
844
+ return {
845
+ // Drop the input aliases AND the readback-only keys (`code`/`index` come
846
+ // from workflowSummary): the merge override is spread onto the draft row,
847
+ // so an unstripped alias would ride the wire and make the platform answer
848
+ // a misleading 401001 (same policy as mapCallerActivities).
849
+ ...Object.fromEntries(Object.entries(raw).filter(([key]) => !ACTIVITY_INPUT_ALIAS_KEYS.includes(key))),
850
+ activityCode: activityCodeOf(raw),
851
+ ...(type ? { activityType: type } : {}),
852
+ ...(name !== undefined ? { activityName: name, i18nMessage: name } : {}),
853
+ // Normalize a participant shorthand to a platform-resolvable form (the
854
+ // merge path bypasses mapCallerActivities; see normalizeParticipantForWire).
855
+ ...(raw.participant !== undefined ? { participant: normalizeParticipantForWire(raw.participant) } : {}),
856
+ };
857
+ });
858
+ // Live 2026-09-23: the platform cannot rename an existing activityCode — the
859
+ // stored name/type win (same constraint the replace path reconciles). A
860
+ // caller that re-sends the readback with a changed name/type must get a FRESH
861
+ // code, and the old node must be DROPPED so it is a rename, not a duplicate.
862
+ const { activities: reconciledCaller, codeMap } = reconcileCallerActivityCodes(caller, draftActivities);
863
+ const draftByCode = new Map(draftActivities.map((activity) => [String(activity.activityCode ?? ""), activity]));
864
+ const originalOf = new Map();
865
+ for (const [original, final] of codeMap)
866
+ if (String(original) !== String(final))
867
+ originalOf.set(String(final), String(original));
868
+ // Map each caller node back to the DRAFT code it targets, so an override
869
+ // (including a rename) replaces that draft row IN PLACE instead of moving
870
+ // the node to the end of the chain. A renamed node inherits the old row's
871
+ // subfaces (geometry, sheetCode, todoDataItems, …) — only code/name/type change.
872
+ const callerByOriginal = new Map();
873
+ for (const activity of reconciledCaller) {
874
+ const final = String(activity.activityCode);
875
+ const original = originalOf.get(final) ?? final;
876
+ const base = draftByCode.get(original);
877
+ callerByOriginal.set(original, base ? deepMergeActivityRow(base, activity) : activity);
878
+ }
879
+ const mergedActivities = draftActivities.map((activity) => {
880
+ const key = String(activity.activityCode ?? "");
881
+ const override = callerByOriginal.get(key);
882
+ if (!override)
883
+ return activity;
884
+ callerByOriginal.delete(key);
885
+ return { ...activity, ...override };
886
+ });
887
+ const newActivities = [...callerByOriginal.values()];
888
+ // Insert added nodes BEFORE the terminal END, so the auto-chain reads
889
+ // START → …existing… → new → END (appending after END produced a chain that
890
+ // ended on the new node instead of END).
891
+ if (newActivities.length > 0) {
892
+ let endIndex = mergedActivities.length;
893
+ for (let index = mergedActivities.length - 1; index >= 0; index -= 1) {
894
+ if (String(mergedActivities[index]?.activityType ?? "") === "END") {
895
+ endIndex = index;
896
+ break;
897
+ }
898
+ }
899
+ mergedActivities.splice(endIndex, 0, ...newActivities);
900
+ }
901
+ const remapCode = (value) => codeMap.get(String(value ?? "")) ?? value;
902
+ // Both draft edges and caller edges follow a renamed node; the caller may use
903
+ // any endpoint spelling, so remap every alias.
904
+ const remapRuleEndpoints = (rule) => {
905
+ const mapped = { ...rule };
906
+ for (const key of ["preActivityCode", "preNode", "pre", "from"])
907
+ if (rule[key] !== undefined)
908
+ mapped[key] = remapCode(rule[key]);
909
+ for (const key of ["postActivityCode", "postNode", "post", "to"])
910
+ if (rule[key] !== undefined)
911
+ mapped[key] = remapCode(rule[key]);
912
+ return mapped;
913
+ };
914
+ const edgeKey = (rule) => `${rule.preActivityCode ?? rule.preNode ?? rule.pre ?? rule.from}->${rule.postActivityCode ?? rule.postNode ?? rule.post ?? rule.to}`;
915
+ const remappedDraftRules = draftRules.map(remapRuleEndpoints);
916
+ const mappedCallerRoutes = Array.isArray(routes) ? mapCallerRules(routes.map((entry) => remapRuleEndpoints((entry ?? {}))), remappedDraftRules, mergedActivities) : [];
917
+ const byEdge = new Map();
918
+ for (const rule of remappedDraftRules)
919
+ byEdge.set(edgeKey(rule), rule);
920
+ for (const rule of mappedCallerRoutes)
921
+ byEdge.set(edgeKey(rule), { ...(byEdge.get(edgeKey(rule)) ?? {}), ...rule });
922
+ const mergedRules = completeWorkflowChain([...byEdge.values()], mergedActivities);
923
+ // Same layout + points derivation as the replace path: a chain-completion edge
924
+ // synthesized above carries no points, and the designer canvas needs them.
925
+ const laidOut = canonicalizeWorkflowLayout(mergedActivities, mergedRules);
926
+ const rules = mergedRules.map((rule) => {
927
+ const absent = rule.points === undefined || rule.points === null;
928
+ return laidOut.changed || absent
929
+ ? { ...rule, points: deriveRulePoints(String(rule.preActivityCode ?? ""), String(rule.postActivityCode ?? ""), laidOut.activities) }
930
+ : rule;
931
+ });
932
+ return { ...rest, activities: laidOut.activities, rules };
933
+ }
934
+ function normalizeWorkflowTemplate(template, draft) {
935
+ if (template === undefined || template === null)
936
+ return {};
937
+ const { routes: routesRaw, activities: rawActivities, edges: edgesRaw, ...rest } = template;
938
+ const routes = routesRaw ?? edgesRaw;
939
+ void rawActivities;
940
+ // Array OR index-keyed object; `rest` must not carry the raw form or an
941
+ // object-shaped activities would ride the wire unmerged.
942
+ const rawCaller = callerActivities(template);
943
+ const draftActivities = Array.isArray(draft.activities) ? draft.activities : [];
944
+ const draftRules = Array.isArray(draft.rules) ? draft.rules : [];
945
+ // The platform cannot rename an existing activityCode (see the reconcile
946
+ // comment); give every changed/new node a fresh code, and remap caller routes
947
+ // so they follow.
948
+ const reconciled = rawCaller ? reconcileCallerActivityCodes(rawCaller, draftActivities) : undefined;
949
+ const activities = reconciled?.activities;
950
+ const codeMap = reconciled?.codeMap ?? new Map();
951
+ const remapCode = (value) => codeMap.get(String(value ?? "")) ?? value;
952
+ const remappedRoutes = Array.isArray(routes)
953
+ ? routes.map((entry) => {
954
+ const record = (entry ?? {});
955
+ return {
956
+ ...record,
957
+ ...(record.preNode !== undefined ? { preNode: remapCode(record.preNode) } : {}),
958
+ ...(record.postNode !== undefined ? { postNode: remapCode(record.postNode) } : {}),
959
+ ...(record.pre !== undefined ? { pre: remapCode(record.pre) } : {}),
960
+ ...(record.post !== undefined ? { post: remapCode(record.post) } : {}),
961
+ };
962
+ })
963
+ : routes;
964
+ const normalized = { ...rest };
965
+ if (activities)
966
+ normalized.activities = mapCallerActivities(activities, draftActivities);
967
+ if (Array.isArray(remappedRoutes)) {
968
+ const mapped = mapCallerRules(remappedRoutes, draftRules, normalized.activities ?? draftActivities);
969
+ normalized.rules = Array.isArray(rest.rules) ? [...rest.rules, ...mapped] : mapped;
970
+ }
971
+ if (Array.isArray(rest.rules) && !Array.isArray(remappedRoutes))
972
+ normalized.rules = mapCallerRules(rest.rules, draftRules, normalized.activities ?? draftActivities);
973
+ if (activities && !Array.isArray(remappedRoutes) && !Array.isArray(rest.rules)) {
974
+ const mappedActivities = normalized.activities;
975
+ const remapped = draftRules.map((rule) => {
976
+ const preIndex = draftActivities.findIndex((activity) => String(activity.activityCode ?? "") === String(rule.preActivityCode ?? ""));
977
+ const postIndex = draftActivities.findIndex((activity) => String(activity.activityCode ?? "") === String(rule.postActivityCode ?? ""));
978
+ const pre = mappedActivities[preIndex]?.activityCode ?? rule.preActivityCode;
979
+ const post = mappedActivities[postIndex]?.activityCode ?? rule.postActivityCode;
980
+ return { ...rule, preActivityCode: pre, postActivityCode: post };
981
+ });
982
+ normalized.rules = completeWorkflowChain(remapped, mappedActivities);
983
+ }
984
+ // Live 2026-09-17: merged activities may collide (two PARTICIPANT nodes both
985
+ // inheriting the draft PARTICIPANT's box) — lay the chain out canonically,
986
+ // then re-derive every rule's points from the final geometry so the designer
987
+ // lines follow the boxes instead of drawing a horizontal stub.
988
+ if (Array.isArray(normalized.activities)) {
989
+ const laidOut = canonicalizeWorkflowLayout(normalized.activities, Array.isArray(normalized.rules) ? normalized.rules : []);
990
+ normalized.activities = laidOut.activities;
991
+ // Caller-authored points on an already-renderable layout stay verbatim;
992
+ // points are re-derived when the geometry was repaired — or when a rule
993
+ // carries none at all (a chain-completion edge synthesized above). A
994
+ // provided-but-malformed points value is left for validateWorkflowRender to
995
+ // refuse (never silently repaired).
996
+ if (Array.isArray(normalized.rules))
997
+ normalized.rules = normalized.rules.map((rule) => {
998
+ const absent = rule.points === undefined || rule.points === null;
999
+ return laidOut.changed || absent
1000
+ ? { ...rule, points: deriveRulePoints(String(rule.preActivityCode ?? ""), String(rule.postActivityCode ?? ""), laidOut.activities) }
1001
+ : rule;
1002
+ });
1003
+ }
1004
+ return normalized;
1005
+ }
1006
+ /**
1007
+ * Ticket 112 — merge the caller template onto the FULL get_draft row before
1008
+ * the update/publish wire. The platform save is a full-row write; posting a
1009
+ * minimal template leaves converter-swallowed holes and answers errcode
1010
+ * 50000 (live incident). Caller keys override; everything else echoes the
1011
+ * draft verbatim (the same merge pattern workflow.bindRule uses).
1012
+ */
1013
+ async function mergedTemplateBody(session, schemaCode, code, template, merge = false) {
1014
+ const draft = await getWorkflowDraftOrNull(session, code);
1015
+ if (draft == null)
1016
+ throw new ToolRefusalError({
1017
+ status: "refused",
1018
+ blocker: "workflow-not-found",
1019
+ workflowCode: code,
1020
+ message: `流程 ${code} 不存在(get_draft 401001 缺席)——先 workflow.create 再 update/publish`,
1021
+ });
1022
+ const normalized = merge ? mergeCallerDelta(template, draft) : normalizeWorkflowTemplate(template, draft);
1023
+ // S29 contract: a PARTICIPANT activity requires sheetCode = the schema's
1024
+ // sheet, else validate/publish answers 401026 WORKFLOW_ACTIVITY_NO_SHEET. The
1025
+ // platform's own draft sets each participant's sheetCode to the model code;
1026
+ // an activity the caller ADDED has no draft row to inherit it from, so
1027
+ // backfill it (the default-form slot is the model code — same rule as
1028
+ // configure_form's omitted sheetCode).
1029
+ if (Array.isArray(normalized.activities))
1030
+ normalized.activities = normalized.activities.map((activity) => String(activity.activityType ?? "") === "PARTICIPANT" && (activity.sheetCode === undefined || activity.sheetCode === null || activity.sheetCode === "")
1031
+ ? { ...activity, sheetCode: schemaCode }
1032
+ : activity);
1033
+ return { ...draft, ...normalized, schemaCode, workflowCode: code };
1034
+ }
1035
+ /**
1036
+ * Round-2 BUG-05 — live `workflow/get_header` and `workflow/get_draft` answer
1037
+ * `401001 流程不存在!` for an ABSENT workflow rather than a null payload. Both
1038
+ * are ABSENCE signals, not failures: the pre-create probe and the post-create
1039
+ * readback must treat them as "no workflow" so workflow.create can proceed
1040
+ * instead of stopping at the probe's 401001.
1041
+ */
1042
+ function isWorkflowAbsent(error) {
1043
+ return error instanceof WireError && error.kind === "business" && error.errcode === 401001;
1044
+ }
1045
+ /**
1046
+ * Identity seam: a caller may hold only the model's `schemaCode`. Resolve the
1047
+ * workflow it backs via the platform link (workflow/isworkflow →
1048
+ * workflow/getWorkflow); return null when the model has no workflow.
1049
+ */
1050
+ async function resolveWorkflowArg(session, args) {
1051
+ if (args.workflowCode)
1052
+ return args.workflowCode;
1053
+ if (!args.schemaCode)
1054
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "workflow.*", message: "需要 workflowCode,或 schemaCode(工具内部解析该模型的流程)" });
1055
+ const code = (await wireCall(session, { path: ENDPOINTS.workflowIsWorkflow, query: { schemaCode: args.schemaCode } })).data === true
1056
+ ? (await wireCall(session, { path: ENDPOINTS.workflowGetBySchema, query: { schemaCode: args.schemaCode } })).data
1057
+ : null;
1058
+ const workflowCode = code && typeof code.workflowCode === "string" && code.workflowCode ? code.workflowCode : null;
1059
+ return workflowCode;
1060
+ }
1061
+ async function getWorkflowHeaderOrNull(session, workflowCode) {
1062
+ try {
1063
+ return ((await wireCall(session, { path: ENDPOINTS.workflowGetHeader, query: { workflowCode } })).data ?? null);
1064
+ }
1065
+ catch (error) {
1066
+ if (isWorkflowAbsent(error))
1067
+ return null;
1068
+ throw error;
1069
+ }
1070
+ }
1071
+ async function getWorkflowDraftOrNull(session, workflowCode) {
1072
+ try {
1073
+ return ((await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode } })).data ?? null);
1074
+ }
1075
+ catch (error) {
1076
+ if (isWorkflowAbsent(error))
1077
+ return null;
1078
+ throw error;
1079
+ }
1080
+ }
1081
+ /**
1082
+ * Round 4 (context-cost): keep the node structure in the default workflow
1083
+ * readback. The full template row is 30–75 KB (activities ×4, originateActivity,
1084
+ * notify config); without this projection the agent fetches the whole artifact
1085
+ * just to see the node list. Reproduced benchmark: `artifact get` was 56% of a
1086
+ * complex task's stdout, dominated by workflow.get_draft/get_published (75 KB +
1087
+ * 52 KB). Full body stays reachable via detailArtifactId, `--jq`, or `--full`.
1088
+ */
1089
+ /**
1090
+ * Round 16: compact, non-default field-permission summary for one activity.
1091
+ * The full permission row carries ~10 keys per field (`propertyName`,
1092
+ * `encryptVisible`, `decryptAble`, …); the caller only needs to confirm who can
1093
+ * see/edit/require what, so keep `{code, visible, editable, required}` and drop
1094
+ * the all-false rows that dominate the full body. Returns undefined when the
1095
+ * activity declares no permission face or has no non-default row.
1096
+ */
1097
+ function activityPermissions(activity) {
1098
+ const rows = activity.propertyPermissions;
1099
+ if (!Array.isArray(rows))
1100
+ return undefined;
1101
+ const permissions = rows
1102
+ .filter((row) => Boolean(row && typeof row === "object" && !Array.isArray(row)))
1103
+ .map((row) => ({ code: row.propertyCode ?? null, visible: row.visible === true, editable: row.editable === true, required: row.required === true }))
1104
+ .filter((row) => row.visible || row.editable || row.required);
1105
+ return permissions.length > 0 ? { permissions } : undefined;
1106
+ }
1107
+ export function workflowCompactProjection(result) {
1108
+ const data = (result.data ?? null);
1109
+ if (data === null)
1110
+ return {};
1111
+ const activities = (data.activities ?? {});
1112
+ const nodes = Object.entries(activities).map(([index, activity]) => ({
1113
+ index: Number(index),
1114
+ code: activity.activityCode ?? null,
1115
+ type: activity.activityType ?? null,
1116
+ name: activity.activityName ?? activity.i18nMessage ?? null,
1117
+ ...(activity.participant !== undefined ? { participant: activity.participant } : {}),
1118
+ // Round 16 (bench65 feedback): a participant node's field permissions were
1119
+ // only visible in the 30–45 KB full body, so acceptance (and any "did my
1120
+ // permission edit land?") required the artifact. Surface only the
1121
+ // NON-DEFAULT rows (any of visible/editable/required true) so the summary
1122
+ // stays small and names exactly what a readback should confirm.
1123
+ ...(activityPermissions(activity) ?? {}),
1124
+ }));
1125
+ const rules = data.rules;
1126
+ const edges = Array.isArray(rules)
1127
+ ? rules.map((rule) => {
1128
+ const record = rule;
1129
+ return { from: record.preActivityCode ?? null, to: record.postActivityCode ?? null, ...workflowRouteConditionView(record) };
1130
+ })
1131
+ : [];
1132
+ // Round 15 (bench61 feedback): a count ("ruleBindings: 3") cannot be verified —
1133
+ // show WHICH event handler binds WHICH rule codes, so a readback confirms the
1134
+ // binding without pulling the full body. The platform's own originate node
1135
+ // (填写申请单) and any injected participant are named too, so a caller does not
1136
+ // mistake them for their declared nodes.
1137
+ const ruleBindings = [];
1138
+ for (const [handler, field] of Object.entries(WORKFLOW_EVENT_HANDLER_FIELDS)) {
1139
+ const config = data[field];
1140
+ const codes = Array.isArray(config?.bizActions) ? config.bizActions.filter((value) => typeof value === "string") : [];
1141
+ if (codes.length > 0 && config?.bizActionType === "BIZ_RULE")
1142
+ ruleBindings.push({ handler, field, ruleCodes: codes });
1143
+ }
1144
+ const originate = data.originateActivity;
1145
+ return {
1146
+ workflowSummary: {
1147
+ workflowCode: data.workflowCode ?? result.workflowCode ?? null,
1148
+ templateType: data.templateType ?? null,
1149
+ activities: nodes,
1150
+ edges,
1151
+ ruleBindings,
1152
+ ruleBindingCount: ruleBindings.reduce((sum, binding) => sum + binding.ruleCodes.length, 0),
1153
+ ...(originate ? { originateActivity: { code: originate.activityCode ?? null, type: originate.activityType ?? null, name: originate.activityName ?? originate.i18nMessage ?? null, participant: originate.participant ?? null } } : {}),
1154
+ },
1155
+ // Editing needs only `activities` (array or index object) + `routes`: the tool
1156
+ // merges onto the stored draft, connects added nodes through to END, fills
1157
+ // sheetCode and geometry. Reading the 30–75 KB full body is NOT required —
1158
+ // bench17 pulled 61 KB `full:true` just to edit two node names.
1159
+ summaryHint: "改流程只需把要改/新增的 activities(数组)+ routes 传给 workflow.update:缺 START/END 的部分模板会自动按增量 merge 到草稿(草稿为基,自动补 sheetCode/连线/坐标),无需读回整模板或 --full 正文;字段权限也只需在对应 PARTICIPANT 上给 propertyPermissions:[{propertyCode,...}],工具按 propertyCode 合并、未列的字段权限保留。只有要整图替换时才给全 START..END 的完整模板;要看通知等完整节点正文才用 detailArtifactId 或 --full",
1160
+ };
1161
+ }
1162
+ /**
1163
+ * A workflow event handler (start/cancel/activate/end) binds a business rule by
1164
+ * CODE. The platform's designer rule picker only offers CUSTOM rules — the
1165
+ * preset trigger rows (Create/Update/Delete/Load/GetList/Available/Cancel, all
1166
+ * `defaultRule:true`) are NOT selectable there, because they already run on the
1167
+ * data event itself. Binding one (2026-09-28: bench59 bound `Create` to `end`)
1168
+ * produces a handler the designer cannot represent. Refuse before any write.
1169
+ */
1170
+ async function assertBindableWorkflowRules(session, schemaCode, codes) {
1171
+ const presets = codes.filter((code) => PLATFORM_TRIGGER_RULE_CODES.has(code));
1172
+ if (presets.length > 0)
1173
+ throw new ToolRefusalError({
1174
+ status: "refused",
1175
+ blocker: "workflow-bind-preset-rule",
1176
+ ruleCodes: presets,
1177
+ message: `流程事件只能绑定【自定义】业务规则,不能绑定平台预置触发规则(${presets.join("、")})——预置规则已在数据事件上运行,设计器的流程事件规则选择器不提供它们。要「审批通过后执行某逻辑」,请新建一条自定义规则(自定义 ruleCode),把动作节点写进它,再 bindRule 到对应 handler;或在预置 Create/Update 规则里直接追加节点。`,
1178
+ });
1179
+ // Cross-check the authoritative `defaultRule` flag (covers preset codes not in
1180
+ // the local set, and a schema that renamed one).
1181
+ let rows;
1182
+ try {
1183
+ const list = await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode } });
1184
+ rows = wireRows(list.data);
1185
+ }
1186
+ catch {
1187
+ return new Map(); // a read failure must not block an otherwise-valid bind
1188
+ }
1189
+ const byCode = new Map(rows.map((row) => [String(row.code ?? ""), row]));
1190
+ const defaults = codes.filter((code) => byCode.get(code)?.defaultRule === true && !PLATFORM_TRIGGER_RULE_CODES.has(code));
1191
+ if (defaults.length > 0)
1192
+ throw new ToolRefusalError({
1193
+ status: "refused",
1194
+ blocker: "workflow-bind-preset-rule",
1195
+ ruleCodes: defaults,
1196
+ message: `流程事件只能绑定自定义业务规则;${defaults.join("、")} 是平台预置默认规则(defaultRule:true),设计器不可选。改用自定义规则。`,
1197
+ });
1198
+ return byCode;
1199
+ }
1200
+ /**
1201
+ * A rule bound to a workflow event handler is INERT while disabled — the
1202
+ * designer shows it 失效/未启用 and the event handler will not run it. Binding a
1203
+ * workflow rule therefore GUARANTEES the bound custom rule is enabled: a
1204
+ * disabled/never-published rule (the state `rule.save` leaves) is published +
1205
+ * enabled here via the same `rule.enable` chain. Live 2026-10-08 (SCM sandbox):
1206
+ * rules created with `rule.save` (DRAFT/DISABLED) and bound with
1207
+ * `workflow.bindRule` read back 「失效·未启用」 in the workflow designer — a
1208
+ * workflow-bound rule MUST be enabled.
1209
+ */
1210
+ async function ensureWorkflowRulesEnabled(session, schemaCode, codes, byCode) {
1211
+ const enabledRuleCodes = [];
1212
+ const alreadyEnabledRuleCodes = [];
1213
+ if (byCode.size === 0)
1214
+ return { enabledRuleCodes, alreadyEnabledRuleCodes }; // list read failed → cannot judge
1215
+ for (const code of codes) {
1216
+ const row = byCode.get(code);
1217
+ if (!row)
1218
+ throw new ToolRefusalError({
1219
+ status: "refused",
1220
+ blocker: "workflow-bind-rule-absent",
1221
+ schemaCode,
1222
+ ruleCodes: [code],
1223
+ message: `流程事件要绑定的规则 "${code}" 不存在(${schemaCode} 的 list_by_schemacode 无此 code)——流程事件只能绑已存在的【自定义】规则。先用 rule.save 建该规则(CUSTOM_NODE + 动作节点),再 bindRule。`,
1224
+ });
1225
+ if (row.enabled === true) {
1226
+ alreadyEnabledRuleCodes.push(code);
1227
+ continue;
1228
+ }
1229
+ // Disabled/DRAFT: publish + enable so the event handler actually runs it.
1230
+ await executeRuleEnableWrite(session, { schemaCode, ruleCode: code });
1231
+ enabledRuleCodes.push(code);
1232
+ }
1233
+ return { enabledRuleCodes, alreadyEnabledRuleCodes };
1234
+ }
1235
+ export function registerWorkflowLane(tools) {
1236
+ // Sibling registry for the high-level `configure_workflow` (reuses each atomic
1237
+ // tool's own writeExecute so every gate + readback is identical).
1238
+ const siblings = tools;
1239
+ tools.push(defineTool({
1240
+ name: "workflow.get_draft",
1241
+ layer: "low",
1242
+ kind: "read",
1243
+ lane: WORKFLOW_LANE,
1244
+ scope: WORKFLOW_TOOLS["workflow.get_draft"].scope,
1245
+ description: WORKFLOW_TOOLS["workflow.get_draft"].description,
1246
+ inputSchema: {
1247
+ environment: environmentArg,
1248
+ workflowCode: z.string().optional().describe("流程 code;也可改用 schemaCode(工具内部按 workflow/isworkflow + workflow/getWorkflow 解析)"),
1249
+ schemaCode: z.string().optional().describe("模型 code:省略 workflowCode 时用它解析该模型已发布的流程;模型没有流程则 exists:false"),
1250
+ full: z.boolean().optional().describe("内联完整工作流模板;默认返回摘要与 detailFile"),
1251
+ capability: capabilityArg,
1252
+ },
1253
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
1254
+ // Identity seam: callers often hold only the model's schemaCode; resolve the
1255
+ // workflow it backs rather than forcing an external mapping.
1256
+ const workflowCode = await resolveWorkflowArg(session, args);
1257
+ if (!workflowCode)
1258
+ return { schemaCode: args.schemaCode, exists: false, note: "该模型没有已发布流程(workflow/isworkflow=false);先 workflow.create" };
1259
+ // Round-2 BUG-05: a missing workflow answers 401001 — surface absence as
1260
+ // a clean readback (exists:false) instead of a confusing error.
1261
+ const draft = await getWorkflowDraftOrNull(session, workflowCode);
1262
+ return draft == null
1263
+ ? { workflowCode, exists: false, note: "流程不存在(平台 401001 = 缺席信号);先 workflow.create" }
1264
+ : { workflowCode, exists: true, data: draft };
1265
+ }, { full: args.full === true, compact: workflowCompactProjection, target: `workflow.get_draft:${args.workflowCode ?? args.schemaCode}` }),
1266
+ }));
1267
+ tools.push(defineTool({
1268
+ name: "workflow.get_published",
1269
+ layer: "low",
1270
+ kind: "read",
1271
+ lane: WORKFLOW_LANE,
1272
+ scope: WORKFLOW_TOOLS["workflow.get_published"].scope,
1273
+ description: WORKFLOW_TOOLS["workflow.get_published"].description,
1274
+ inputSchema: {
1275
+ environment: environmentArg,
1276
+ workflowCode: z.string().optional().describe("流程 code;也可改用 schemaCode(工具内部按 workflow/isworkflow + workflow/getWorkflow 解析)"),
1277
+ schemaCode: z.string().optional().describe("模型 code:省略 workflowCode 时用它解析该模型已发布的流程;模型没有流程则 exists:false"),
1278
+ full: z.boolean().optional().describe("内联完整工作流模板;默认返回摘要与 detailFile"),
1279
+ capability: capabilityArg,
1280
+ },
1281
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
1282
+ const workflowCode = await resolveWorkflowArg(session, args);
1283
+ if (!workflowCode)
1284
+ return { schemaCode: args.schemaCode, exists: false, published: false, note: "该模型没有已发布流程(workflow/isworkflow=false);先 workflow.create 再 publish" };
1285
+ let pub;
1286
+ try {
1287
+ pub = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode } });
1288
+ }
1289
+ catch (error) {
1290
+ // Live `workflow/get_published` also answers 401001 流程不存在! for an
1291
+ // absent workflow. Mirror workflow.get_draft: surface absence as a
1292
+ // clean exists:false readback so a greenfield probe does not have to
1293
+ // create the workflow first.
1294
+ if (isWorkflowAbsent(error))
1295
+ return { workflowCode, exists: false, published: false, note: "流程不存在(平台 401001 = 缺席信号);先 workflow.create 再 publish" };
1296
+ throw error;
1297
+ }
1298
+ // Ticket 77: the platform answers the DRAFT shape for an unpublished
1299
+ // workflow — data presence is NOT publish evidence. The published face
1300
+ // is identified by its markers (templateType PUBLISH / published flag).
1301
+ const data = (pub.data ?? null);
1302
+ const published = data !== null && (data.templateType === "PUBLISH" || data.published === true);
1303
+ return {
1304
+ workflowCode,
1305
+ exists: true,
1306
+ published,
1307
+ ...(data && !published ? { note: "平台对未发布流程仍返回草稿形态——此读回不能当作已发布证据;以 published:true / templateType:PUBLISH 为准(先 workflow.publish)" } : {}),
1308
+ data: pub.data,
1309
+ };
1310
+ }, { full: args.full === true, compact: workflowCompactProjection, target: `workflow.get_published:${args.workflowCode ?? args.schemaCode}` }),
1311
+ }));
1312
+ tools.push(defineTool({
1313
+ name: "workflow.validate",
1314
+ layer: "low",
1315
+ kind: "read",
1316
+ lane: WORKFLOW_LANE,
1317
+ scope: WORKFLOW_TOOLS["workflow.validate"].scope,
1318
+ description: WORKFLOW_TOOLS["workflow.validate"].description,
1319
+ inputSchema: {
1320
+ environment: environmentArg,
1321
+ workflowCode: z.string().optional().describe("要校验的流程;省略则用 schemaCode 解析"),
1322
+ schemaCode: z.string().optional().describe("模型 code:省略 workflowCode 时用它解析该模型的流程"),
1323
+ template: workflowTemplateArg.optional().describe("可选:先按 workflow.update 的 merge 语义合成该模板再校验;省略则校验当前草稿"),
1324
+ capability: capabilityArg,
1325
+ },
1326
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
1327
+ const workflowCode = await resolveWorkflowArg(session, args);
1328
+ if (!workflowCode)
1329
+ return { schemaCode: args.schemaCode, exists: false, note: "该模型没有流程可校验" };
1330
+ const template = (args.template ?? {});
1331
+ const body = await mergedTemplateBody(session, args.schemaCode ?? "", workflowCode, template, activitiesLackBoundaries(template));
1332
+ const result = await wireCall(session, { path: ENDPOINTS.workflowValidate, method: "POST", body });
1333
+ const messages = Array.isArray(result.data) ? result.data : [];
1334
+ const errors = messages.filter((message) => message.errorLevel === "ERROR");
1335
+ const warnings = messages.filter((message) => message.errorLevel !== "ERROR");
1336
+ return { workflowCode, valid: errors.length === 0, errorCount: errors.length, warningCount: warnings.length, errors, warnings };
1337
+ }, { target: `workflow.validate:${args.workflowCode ?? args.schemaCode}` }),
1338
+ }));
1339
+ writeTool(tools, "workflow.create", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.create"].scope, WORKFLOW_TOOLS["workflow.create"].description, { schemaCode: z.string().min(1), name: z.string().min(1), code: z.string().min(1) }, async (session, args) => {
1340
+ // Ticket 76 idempotent retry: get_header FIRST — a post-timeout
1341
+ // re-create of the same code short-circuits with the existing header
1342
+ // and ZERO create wires. On the happy path this read REPLACES the
1343
+ // post-create header read (same wire count), because a successful
1344
+ // create implies the header.
1345
+ const existingHeader = await getWorkflowHeaderOrNull(session, args.code);
1346
+ if (existingHeader != null) {
1347
+ return { performed: { skipped: "already-present" }, readback: { alreadyPresent: true, header: existingHeader } };
1348
+ }
1349
+ let performed;
1350
+ try {
1351
+ performed = await wireCall(session, {
1352
+ path: ENDPOINTS.workflowCreate,
1353
+ method: "POST",
1354
+ body: { workflowCode: args.code, workflowName: args.name, schemaCode: args.schemaCode, icon: "h-icon-all-logic" },
1355
+ });
1356
+ }
1357
+ catch (error) {
1358
+ // Ticket 76 live: the platform answers an envelope with NO numeric
1359
+ // errcode when the SCHEMA already has a workflow (a second, differently
1360
+ // coded one is refused). Decode it as a duplicate-create — same-code
1361
+ // presence was already excluded by the pre-check, so this is the
1362
+ // different-code case — instead of surfacing a malformed-envelope
1363
+ // transport-looking failure on a business duplicate.
1364
+ if (error instanceof WireError && error.kind === "malformed-envelope") {
1365
+ const has = await wireCall(session, { path: ENDPOINTS.workflowIsWorkflow, query: { schemaCode: args.schemaCode } });
1366
+ if (has.data === true) {
1367
+ throw new ToolRefusalError({
1368
+ status: "refused",
1369
+ blocker: "workflow-already-exists",
1370
+ schemaCode: args.schemaCode,
1371
+ workflowCode: args.code,
1372
+ message: `模型 ${args.schemaCode} 已有流程(平台对重复创建返回非数字 errcode 的 envelope)——一个模型只能有一个流程。要改现有流程用 workflow.update/workflow.publish(先 workflow.get_draft 取当前模板),不要另建新 code`,
1373
+ });
1374
+ }
1375
+ }
1376
+ throw error;
1377
+ }
1378
+ const draft = await getWorkflowDraftOrNull(session, args.code);
1379
+ return {
1380
+ performed: { errcode: performed.errcode },
1381
+ readback: { workflowCode: args.code, headerPresent: true, draftPresent: draft != null, templateId: draft?.id ?? null },
1382
+ };
1383
+ });
1384
+ writeTool(tools, "workflow.update", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.update"].scope, WORKFLOW_TOOLS["workflow.update"].description, { schemaCode: z.string().min(1), code: z.string().min(1), template: workflowTemplateArg, merge: z.boolean().optional().describe("增量:只发改动/新增的活动与路由,工具按 activityCode 合并到草稿(草稿为基;新增节点自动接链),不必先读回整模板再重发。默认自动:模板缺 START 或 END 时按增量 merge(部分模板),给了完整 START..END 才整模板替换;显式 true 强制增量、false 强制替换") }, async (session, args) => {
1385
+ const template = args.template ?? {};
1386
+ validateCallerActivities(template, "workflow.update");
1387
+ validateWorkflowRouteConditions(template, "workflow.update");
1388
+ assertTemplateNotMinimal(template, "workflow.update");
1389
+ lintWorkflowTemplate(template, "workflow.update");
1390
+ // Ticket 112: merge onto the full draft row — a verbatim minimal post
1391
+ // leaves converter holes and answers errcode 50000 on the platform.
1392
+ // Delta semantics: explicit `true` forces merge, explicit `false` forces
1393
+ // replace, and the default auto-detects a partial template (missing
1394
+ // START/END) as a delta — see activitiesLackBoundaries.
1395
+ const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
1396
+ const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
1397
+ validateWorkflowRender(body, "workflow.update");
1398
+ // Live: workflow/update does NOT run the template validator — it stores a
1399
+ // refused participant silently and only /publish answers the
1400
+ // ValidateMessage[]. So the guard belongs on publish/bindRule, not here.
1401
+ const performed = await wireCall(session, { path: ENDPOINTS.workflowUpdate, method: "POST", body });
1402
+ const draft = await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.code } });
1403
+ const data = (draft.data ?? null);
1404
+ if (!data)
1405
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.update returned no draft" });
1406
+ validateWorkflowRender(data, "workflow.update readback");
1407
+ const participantWarnings = participantReferenceWarnings(data);
1408
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : 0, templateType: data.templateType ?? null, ...(participantWarnings.length ? { participantWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1409
+ });
1410
+ writeTool(tools, "workflow.publish", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.publish"].scope, WORKFLOW_TOOLS["workflow.publish"].description, { schemaCode: z.string().min(1), code: z.string().min(1), template: workflowTemplateArg.optional() }, async (session, args) => {
1411
+ // An absent template publishes the stored draft as-is — the platform's own
1412
+ // path. The schema is optional so the contract no longer contradicts it.
1413
+ const template = args.template ?? {};
1414
+ validateCallerActivities(template, "workflow.publish");
1415
+ validateWorkflowRouteConditions(template, "workflow.publish");
1416
+ assertTemplateNotMinimal(template, "workflow.publish");
1417
+ lintWorkflowTemplate(template, "workflow.publish");
1418
+ // Ticket 112: same merge-before-write as update. A template without both
1419
+ // boundaries is a partial edit → delta merge (see activitiesLackBoundaries).
1420
+ const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
1421
+ validateWorkflowRender(body, "workflow.publish");
1422
+ const performed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
1423
+ // A refused publish answers errcode 0 — the ValidateMessage[] is the truth.
1424
+ assertWorkflowWriteAccepted(performed, "workflow.publish", args.code);
1425
+ const published = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.code } });
1426
+ const data = (published.data ?? null);
1427
+ // Ticket 77: publish evidence is the published-face MARKER, not data
1428
+ // presence (the endpoint answers the draft shape when unpublished).
1429
+ const isPublished = data !== null && (data.templateType === "PUBLISH" || data.published === true);
1430
+ if (!data)
1431
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.publish returned no published template" });
1432
+ const warnings = workflowValidationWarnings(performed.data);
1433
+ // The platform accepted the write (no ERROR) yet the published face still
1434
+ // shows the draft marker: surface that honestly instead of implying the
1435
+ // flow went live.
1436
+ if (!isPublished)
1437
+ throw new ToolRefusalError({
1438
+ status: "refused",
1439
+ blocker: "workflow-publish-not-applied",
1440
+ workflowCode: args.code,
1441
+ message: `workflow.publish: 平台接受写入(errcode 0)但读回仍是草稿形态(templateType=${String(data.templateType ?? "null")}, published=${String(data.published ?? "null")})——流程未生效。先 workflow.validate 复核模板,修正后重试`,
1442
+ });
1443
+ validateWorkflowRender(data, "workflow.publish readback");
1444
+ const participantWarnings = participantReferenceWarnings(data);
1445
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1446
+ });
1447
+ writeTool(tools, "workflow.bindRule", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.bindRule"].scope, WORKFLOW_TOOLS["workflow.bindRule"].description, {
1448
+ schemaCode: z.string().min(1),
1449
+ workflowCode: z.string().min(1),
1450
+ ruleCode: z.string().min(1).optional().describe("single-rule form: the rule to bind (mutually exclusive with ruleCodes)"),
1451
+ ruleCodes: z.array(z.string().min(1)).min(1).optional().describe("batch form: several rules bound in ONE update + publish; MERGE semantics — the handler's existing bizActions are preserved and the listed rules appended (deduplicated)"),
1452
+ handler: z
1453
+ .string()
1454
+ .optional()
1455
+ .describe("event handler to bind: start | cancel | activate | end (default end)"),
1456
+ publish: z.boolean().optional(),
1457
+ }, async (session, args) => {
1458
+ const { key: handlerKey, field: handlerField } = resolveEventHandlerField(args.handler);
1459
+ const codes = (args.ruleCodes ?? [args.ruleCode]).map(String);
1460
+ // Only CUSTOM rules are bindable to workflow events — the designer's picker
1461
+ // does not offer preset trigger rows (bench59 bound `Create` to `end`).
1462
+ const bindableByCode = await assertBindableWorkflowRules(session, args.schemaCode, codes);
1463
+ // A bound workflow rule MUST be enabled (else 失效/未启用). Enable any
1464
+ // disabled bound custom rule before writing the binding.
1465
+ const { enabledRuleCodes, alreadyEnabledRuleCodes } = await ensureWorkflowRulesEnabled(session, args.schemaCode, codes, bindableByCode);
1466
+ const shouldPublish = args.publish !== false;
1467
+ const draft = (await getWorkflowDraftOrNull(session, args.workflowCode)) ?? {};
1468
+ if (Object.keys(draft).length === 0) {
1469
+ throw new ToolRefusalError({
1470
+ status: "refused",
1471
+ blocker: "workflow-not-found",
1472
+ workflowCode: args.workflowCode,
1473
+ message: `流程 ${args.workflowCode} 不存在(get_draft 401001 缺席)——先 workflow.create,再 bindRule`,
1474
+ });
1475
+ }
1476
+ const existing = (draft[handlerField] ?? {});
1477
+ const priorActions = Array.isArray(existing.bizActions)
1478
+ ? existing.bizActions.filter((value) => typeof value === "string")
1479
+ : [];
1480
+ // MERGE: existing bizActions are preserved; the listed rules are
1481
+ // appended in order, deduplicated (same semantics the single-rule form
1482
+ // has always had — replace is NOT the contract).
1483
+ const nextActions = [...priorActions];
1484
+ for (const code of codes)
1485
+ if (!nextActions.includes(code))
1486
+ nextActions.push(code);
1487
+ const nextTemplate = {
1488
+ ...draft,
1489
+ [handlerField]: {
1490
+ ...(typeof existing === "object" && existing ? existing : {}),
1491
+ bizActions: nextActions,
1492
+ bizActionType: "BIZ_RULE",
1493
+ },
1494
+ workflowCode: args.workflowCode,
1495
+ schemaCode: args.schemaCode,
1496
+ };
1497
+ validateWorkflowRender(nextTemplate, "workflow.bindRule");
1498
+ const performed = await wireCall(session, {
1499
+ path: ENDPOINTS.workflowUpdate,
1500
+ method: "POST",
1501
+ body: nextTemplate,
1502
+ });
1503
+ let publishedReadback = null;
1504
+ let publishMarked = false;
1505
+ if (shouldPublish) {
1506
+ const publishPerformed = await wireCall(session, {
1507
+ path: ENDPOINTS.workflowPublish,
1508
+ method: "POST",
1509
+ body: nextTemplate,
1510
+ });
1511
+ // A refused publish answers errcode 0 with the truth in ValidateMessage[].
1512
+ assertWorkflowWriteAccepted(publishPerformed, "workflow.bindRule", args.workflowCode);
1513
+ const pub = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.workflowCode } });
1514
+ publishedReadback = (pub.data ?? null);
1515
+ // Authoritative marker, not the request echo (ticket 77 semantics).
1516
+ publishMarked = publishedReadback !== null && (publishedReadback.templateType === "PUBLISH" || publishedReadback.published === true);
1517
+ }
1518
+ const draftReadback = await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.workflowCode } });
1519
+ const data = (draftReadback.data ?? null);
1520
+ if (!data)
1521
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.bindRule returned no draft" });
1522
+ validateWorkflowRender(data, "workflow.bindRule readback");
1523
+ const handler = data ? data[handlerField] : undefined;
1524
+ const bizActions = Array.isArray(handler?.bizActions) ? handler.bizActions.filter((value) => typeof value === "string") : [];
1525
+ const publishedHandler = publishedReadback ? publishedReadback[handlerField] : undefined;
1526
+ const publishedBizActions = Array.isArray(publishedHandler?.bizActions)
1527
+ ? publishedHandler.bizActions.filter((value) => typeof value === "string")
1528
+ : [];
1529
+ return {
1530
+ performed: { errcode: performed.errcode, handler: handlerKey, addedRuleCodes: codes, bizActions, publishRequested: shouldPublish },
1531
+ readback: {
1532
+ workflowCode: args.workflowCode,
1533
+ handler: handlerKey,
1534
+ handlerField,
1535
+ semantics: "merge",
1536
+ bound: codes.every((code) => bizActions.includes(code)),
1537
+ bizActions,
1538
+ // Workflow-bound rules must be enabled; report the enable delta.
1539
+ enabledRuleCodes,
1540
+ alreadyEnabledRuleCodes,
1541
+ bizActionType: typeof handler?.bizActionType === "string" ? handler.bizActionType : null,
1542
+ templateFieldsPreserved: Object.keys(draft).every((key) => key === handlerField || data[key] !== undefined),
1543
+ // Authoritative published-face marker, NOT the request echo: a
1544
+ // refused publish keeps errcode 0 and the draft marker (live 8.6.26).
1545
+ published: publishMarked,
1546
+ publishedBound: codes.every((code) => publishedBizActions.includes(code)),
1547
+ publishedBizActions,
1548
+ publishedBizActionType: typeof publishedHandler?.bizActionType === "string" ? publishedHandler.bizActionType : null,
1549
+ ...(participantReferenceWarnings(data).length ? { participantWarnings: participantReferenceWarnings(data) } : {}),
1550
+ ...(bizActions.length > 1
1551
+ ? { advisory: `同一事件处理器绑定了 ${bizActions.length} 条规则,它们会**全部执行**(没有互斥):审批通过/驳回这类互斥结果不要用两条各自 FIXED 赋值的规则——两次都会写、后者覆盖前者(bench22 病灶:通过→已通过、驳回→已驳回 两条各自 FIXED 赋值都绑 endEventHandler)。正确做法二选一:① **绑定到不同处理器/触发**——通过绑流程结束处理器 \`end\`(endEventHandler)或流程生效规则 \`Available\`,驳回绑流程作废处理器 \`cancel\`(cancelEventHandler)或规则 \`Cancel\`;② **一条规则内部按条件分支**(规则图用 BRANCH/filterCondition/dataCondition 读审批结果,多分支各写各的)。真实生产(2026-09-23,case_info_update)只在 endEventHandler 绑一条多分支图(updateData),由该图内部分支完成回写。` }
1552
+ : {}),
1553
+ workflowRenderCompat: workflowRenderCompat(data),
1554
+ },
1555
+ };
1556
+ }, (args) => {
1557
+ resolveEventHandlerField(args.handler);
1558
+ const hasSingle = typeof args.ruleCode === "string" && args.ruleCode.length > 0;
1559
+ const hasMany = Array.isArray(args.ruleCodes);
1560
+ if (hasSingle && hasMany)
1561
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-exclusive", message: "ruleCode 与 ruleCodes 互斥:单规则用 ruleCode,多规则用 ruleCodes[]" });
1562
+ if (!hasSingle && !hasMany)
1563
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "需要 ruleCode(单个)或 ruleCodes(数组)之一" });
1564
+ if (hasMany) {
1565
+ const codes = args.ruleCodes;
1566
+ if (codes.length === 0 || new Set(codes.map(String)).size !== codes.length || codes.some((code) => typeof code !== "string" || code.length === 0))
1567
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "ruleCodes 必须是非空、去重的规则 code 数组" });
1568
+ }
1569
+ });
1570
+ writeTool(tools, "workflow.delete", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.delete"].scope, WORKFLOW_TOOLS["workflow.delete"].description, { schemaCode: z.string().min(1), code: z.string().min(1) }, async (session, args) => {
1571
+ const performed = await wireCall(session, {
1572
+ path: ENDPOINTS.workflowDelete,
1573
+ method: "POST",
1574
+ query: { workflowCode: args.code },
1575
+ });
1576
+ const header = await wireCall(session, { path: ENDPOINTS.workflowGetHeader, query: { workflowCode: args.code } }).catch(() => ({ data: null }));
1577
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, absent: header.data === null || header.data === undefined } };
1578
+ });
1579
+ // ---- high-level greenfield orchestration (round 10 ergonomics) --------
1580
+ // The survey's write-face gap: a first workflow took create → update →
1581
+ // publish (+ bindRule for rules), 3–4 separate CLI calls. configure_workflow
1582
+ // runs the SAME atomic steps through each tool's own writeExecute, so the
1583
+ // gates/readbacks are identical, in one governed package.
1584
+ registerPackageTool(tools, {
1585
+ name: "configure_workflow",
1586
+ lane: WORKFLOW_LANE,
1587
+ scope: WORKFLOW_TOOLS["configure_workflow"].scope,
1588
+ description: WORKFLOW_TOOLS["configure_workflow"].description,
1589
+ inputSchema: {
1590
+ schemaCode: z.string().min(1),
1591
+ name: z.string().min(1),
1592
+ code: z.string().min(1).describe("workflowCode"),
1593
+ template: workflowTemplateArg.describe("完整模板(activities + routes);工具内部合并到草稿,含条件分支写法见 workflow.update"),
1594
+ bindRules: z
1595
+ .array(z.object({ handler: z.string().optional(), ruleCodes: z.array(z.string().min(1)).min(1) }))
1596
+ .optional()
1597
+ .describe("可选:发布后绑定业务规则;每项 {handler?,ruleCodes[]},handler 取 start|cancel|activate|end(缺省 end)"),
1598
+ },
1599
+ plan: (args) => {
1600
+ const actions = [
1601
+ { tool: "workflow.create", args: { schemaCode: args.schemaCode, name: args.name, code: args.code } },
1602
+ { tool: "workflow.update", args: { schemaCode: args.schemaCode, code: args.code, template: args.template } },
1603
+ { tool: "workflow.publish", args: { schemaCode: args.schemaCode, code: args.code } },
1604
+ ];
1605
+ for (const binding of args.bindRules ?? [])
1606
+ actions.push({ tool: "workflow.bindRule", args: { schemaCode: args.schemaCode, workflowCode: args.code, ruleCodes: binding.ruleCodes, ...(binding.handler ? { handler: binding.handler } : {}) } });
1607
+ return { summary: writeSummary("configure_workflow", args, undefined, "plan"), actions };
1608
+ },
1609
+ execute: async (session, args) => {
1610
+ const steps = [];
1611
+ const run = async (toolName, toolArgs) => {
1612
+ const target = siblings.find((tool) => tool.name === toolName);
1613
+ if (!target?.writeExecute)
1614
+ throw new ToolRefusalError({ status: "refused", blocker: "configure-workflow-step-unavailable", message: `configure_workflow 需要 ${toolName},但它未注册写执行` });
1615
+ try {
1616
+ const outcome = await target.writeExecute(session, toolArgs);
1617
+ steps.push({ step: toolName, ...outcome });
1618
+ return outcome.readback;
1619
+ }
1620
+ catch (error) {
1621
+ const envelope = error instanceof ToolRefusalError ? { ...error.payload } : error instanceof WireError ? { blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message } : { blocker: "configure-workflow-step-failed", message: String(error) };
1622
+ return { __failed: true, tool: toolName, index: steps.length, envelope };
1623
+ }
1624
+ };
1625
+ for (const [toolName, toolArgs] of [
1626
+ ["workflow.create", { schemaCode: args.schemaCode, name: args.name, code: args.code }],
1627
+ ["workflow.update", { schemaCode: args.schemaCode, code: args.code, template: args.template }],
1628
+ ["workflow.publish", { schemaCode: args.schemaCode, code: args.code }],
1629
+ ]) {
1630
+ const readback = await run(toolName, toolArgs);
1631
+ if (readback && readback.__failed)
1632
+ return { status: "refused", steps, failedStep: { index: steps.length, tool: toolName }, envelope: readback.envelope };
1633
+ }
1634
+ for (const binding of args.bindRules ?? []) {
1635
+ const readback = await run("workflow.bindRule", { schemaCode: args.schemaCode, workflowCode: args.code, ruleCodes: binding.ruleCodes, ...(binding.handler ? { handler: binding.handler } : {}) });
1636
+ if (readback && readback.__failed)
1637
+ return { status: "refused", steps, failedStep: { index: steps.length, tool: "workflow.bindRule" }, envelope: readback.envelope };
1638
+ }
1639
+ return { steps, readback: { workflowCode: args.code, published: true, boundHandlers: (args.bindRules ?? []).map((binding) => binding.handler ?? "end") } };
1640
+ },
1641
+ summarize: (args, _outcome, phase) => writeSummary("configure_workflow", args, undefined, phase),
1642
+ });
1643
+ tools.push(defineTool({
1644
+ name: "workitem.list",
1645
+ layer: "low",
1646
+ kind: "read",
1647
+ lane: WORKFLOW_LANE,
1648
+ scope: WORKFLOW_TOOLS["workitem.list"].scope,
1649
+ description: WORKFLOW_TOOLS["workitem.list"].description,
1650
+ inputSchema: {
1651
+ environment: environmentArg,
1652
+ workflowInstanceId: z
1653
+ .string()
1654
+ .optional()
1655
+ .describe("exact-match client-side filter on the returned items' instanceId (the server filterWorkflowInstanceId field is a NOT-equal exclusion, so it is not used)"),
1656
+ workflowCode: z.string().optional(),
1657
+ schemaCode: z.string().optional(),
1658
+ page: z.number().int().min(0).optional(),
1659
+ size: z
1660
+ .number()
1661
+ .int()
1662
+ .min(1)
1663
+ .max(200)
1664
+ .optional()
1665
+ .describe("page size; defaults to 200 when workflowInstanceId is set (single-page scan) else 20"),
1666
+ capability: capabilityArg,
1667
+ },
1668
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
1669
+ const page = await searchWorkItems(session, {
1670
+ ...(args.workflowInstanceId ? { instanceId: args.workflowInstanceId } : {}),
1671
+ ...(args.workflowCode ? { workflowCode: args.workflowCode } : {}),
1672
+ ...(args.schemaCode ? { schemaCode: args.schemaCode } : {}),
1673
+ page: args.page ?? 0,
1674
+ size: args.size ?? (args.workflowInstanceId ? 200 : 20),
1675
+ });
1676
+ const selected = page.items;
1677
+ // Resolve the in-flight bizObject id per distinct instance. Authoritative
1678
+ // source: getBizObjectByWfInstanceId -> BizObjectCreatedModel.id/schemaCode.
1679
+ // Best-effort: a failed lookup leaves the identity null, never drops the todo.
1680
+ const objectByInstance = new Map();
1681
+ for (const item of selected) {
1682
+ const instanceId = wireString(item.instanceId);
1683
+ if (!instanceId || objectByInstance.has(instanceId))
1684
+ continue;
1685
+ objectByInstance.set(instanceId, await resolveBizObjectIdByInstance(session, instanceId));
1686
+ }
1687
+ const items = selected.map((item) => {
1688
+ const instanceId = wireString(item.instanceId);
1689
+ const linked = instanceId ? objectByInstance.get(instanceId) : undefined;
1690
+ return {
1691
+ workItemId: wireString(item.id),
1692
+ workflowInstanceId: instanceId,
1693
+ workflowCode: wireString(item.workflowCode),
1694
+ activityCode: wireString(item.activityCode),
1695
+ activityName: wireString(item.activityName),
1696
+ state: wireString(item.state),
1697
+ sequenceNo: wireString(item.sequenceNo),
1698
+ originator: wireString(item.originator),
1699
+ originatorName: wireString(item.originatorName),
1700
+ participant: wireString(item.participant),
1701
+ participantName: wireString(item.participantName),
1702
+ bizObjectId: linked?.bizObjectId ?? null,
1703
+ schemaCode: linked?.schemaCode ?? wireString(item.schemaCode),
1704
+ };
1705
+ });
1706
+ return {
1707
+ total: page.total,
1708
+ totalPages: page.totalPages,
1709
+ count: items.length,
1710
+ items,
1711
+ };
1712
+ }),
1713
+ }));
1714
+ tools.push(defineTool({
1715
+ name: "org.role.list",
1716
+ layer: "low",
1717
+ kind: "read",
1718
+ lane: WORKFLOW_LANE,
1719
+ scope: WORKFLOW_TOOLS["org.role.list"].scope,
1720
+ description: WORKFLOW_TOOLS["org.role.list"].description,
1721
+ inputSchema: { environment: environmentArg, capability: capabilityArg },
1722
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
1723
+ const data = (await wireCall(session, { path: ENDPOINTS.organizationRoleList })).data;
1724
+ const roles = [];
1725
+ for (const group of wireRows(data)) {
1726
+ const children = Array.isArray(group.children) ? group.children : [];
1727
+ if (children.length === 0)
1728
+ roles.push(roleOf(group, null));
1729
+ else
1730
+ for (const child of children)
1731
+ roles.push(roleOf(child, String(group.name ?? "")));
1732
+ }
1733
+ return { count: roles.length, roles };
1734
+ }),
1735
+ }));
1736
+ tools.push(defineTool({
1737
+ name: "org.department.list",
1738
+ layer: "low",
1739
+ kind: "read",
1740
+ lane: WORKFLOW_LANE,
1741
+ scope: WORKFLOW_TOOLS["org.department.list"].scope,
1742
+ description: WORKFLOW_TOOLS["org.department.list"].description,
1743
+ inputSchema: { environment: environmentArg, capability: capabilityArg },
1744
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
1745
+ const data = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentTree })).data;
1746
+ const list = Array.isArray(data?.departmentList) ? data.departmentList : wireRows(data);
1747
+ const departments = list.map((dept) => ({
1748
+ id: dept.id ?? null,
1749
+ name: dept.name ?? null,
1750
+ code: dept.code ?? null,
1751
+ corpId: dept.corpId ?? null,
1752
+ parentId: dept.parentId ?? null,
1753
+ }));
1754
+ return { count: departments.length, departments };
1755
+ }),
1756
+ }));
1757
+ tools.push(defineTool({
1758
+ name: "org.user.search",
1759
+ layer: "low",
1760
+ kind: "read",
1761
+ lane: WORKFLOW_LANE,
1762
+ scope: WORKFLOW_TOOLS["org.user.search"].scope,
1763
+ description: WORKFLOW_TOOLS["org.user.search"].description,
1764
+ inputSchema: {
1765
+ environment: environmentArg,
1766
+ // `wd` is the platform's own key; `keyword`/`q` are accepted aliases so a
1767
+ // caller that reaches for the natural word is not bounced with
1768
+ // "wd: Required" (round 10: an agent sent `keyword` and burned a retry).
1769
+ wd: z.string().min(1).optional().describe("搜索关键字(用户姓名或账号用户名);平台 \"关键字不能为空\" 即缺它。可用 keyword/q 作别名"),
1770
+ keyword: z.string().min(1).optional().describe("`wd` 的别名"),
1771
+ q: z.string().min(1).optional().describe("`wd` 的别名"),
1772
+ page: z.number().int().min(0).optional().describe("页号,默认 0"),
1773
+ size: z.number().int().min(1).optional().describe("每页条数,默认 100"),
1774
+ filterType: z.string().optional().describe("admin=只搜当前应用管理员可见范围(可省)"),
1775
+ capability: capabilityArg,
1776
+ },
1777
+ handler: async (ctx, args, self) => {
1778
+ const keyword = args.wd ?? args.keyword ?? args.q;
1779
+ if (!keyword)
1780
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "org.user.search", message: "需要 wd(关键字);keyword/q 亦可" });
1781
+ return governedRead(ctx, self, { ...args, wd: keyword }, async (session) => {
1782
+ const body = { wd: keyword, page: args.page ?? 0, size: args.size ?? 100 };
1783
+ if (args.filterType !== undefined)
1784
+ body.filterType = args.filterType;
1785
+ const data = (await wireCall(session, { path: ENDPOINTS.organizationUserSearch, method: "POST", body })).data;
1786
+ const rows = Array.isArray(data?.content) ? data.content : wireRows(data);
1787
+ const users = rows.map((user) => ({
1788
+ id: user.id ?? null,
1789
+ name: user.name ?? null,
1790
+ username: user.username ?? null,
1791
+ userId: user.userId ?? null,
1792
+ code: user.code ?? null,
1793
+ departmentId: user.departmentId ?? null,
1794
+ corpId: user.corpId ?? null,
1795
+ status: user.status ?? null,
1796
+ }));
1797
+ return { keyword, count: users.length, total: data?.totalElements ?? users.length, users };
1798
+ });
1799
+ },
1800
+ }));
1801
+ }
1802
+ /** Flatten a role-group/role row into the participant-construction fields. */
1803
+ function roleOf(row, group) {
1804
+ return {
1805
+ id: row.id ?? null,
1806
+ name: row.name ?? null,
1807
+ code: row.code ?? null,
1808
+ roleType: row.roleType ?? null,
1809
+ corpId: row.corpId ?? null,
1810
+ ...(group !== null ? { group } : {}),
1811
+ };
1812
+ }
1813
+ //# sourceMappingURL=workflow.js.map