@stalfh233/omc-cli 0.4.74 → 0.4.75

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.
@@ -9,7 +9,8 @@ import { writeSummary } from "../human-summary.js";
9
9
  import { defineTool, ToolInputError } from "../tool-types.js";
10
10
  import { wireCall, wireRows, WireError } from "../wire.js";
11
11
  import { diagnoseInstanceActivity, getInstanceBaseInfo, listInstanceActivities, resolveBizObjectIdByInstance, searchWorkItems, wireString } from "../workflow-support.js";
12
- import { WORKFLOW_EVENT_HANDLER_FIELDS, WORKFLOW_LANE_CONTRACT, workflowTemplateArg } from "./workflow-contract.js";
12
+ import { readSchemaFormButtons } from "../form-support.js";
13
+ import { WORKFLOW_EVENT_HANDLER_FIELDS, WORKFLOW_NODE_EVENT_HANDLER_FIELDS, WORKFLOW_LANE_CONTRACT, workflowTemplateArg } from "./workflow-contract.js";
13
14
  import { PLATFORM_TRIGGER_RULE_CODES } from "./rule-graph.js";
14
15
  import { executeRuleEnableWrite } from "./rule-lifecycle.js";
15
16
  const WORKFLOW_LANE = WORKFLOW_LANE_CONTRACT.lane;
@@ -157,7 +158,7 @@ function validateWorkflowRouteConditions(template, tool) {
157
158
  continue;
158
159
  const groups = route.ruleConditionGroup;
159
160
  if (!Array.isArray(groups) || groups.length === 0)
160
- 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)` });
161
+ 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)(平台存量里的空数组写法在 rollback 重放时会自动剥离,调用方不要手写 [])` });
161
162
  for (const [groupIndex, group] of groups.entries()) {
162
163
  if (!Array.isArray(group) || group.length === 0)
163
164
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-route-condition-invalid", message: `${tool}: routes[${index}].ruleConditionGroup[${groupIndex}] must be a non-empty array of RuleConditionItem` });
@@ -193,11 +194,85 @@ function resolveEventHandlerField(handler) {
193
194
  status: "refused",
194
195
  blocker: "workflow-invalid-handler",
195
196
  handler,
196
- message: `handler must be one of ${Object.keys(WORKFLOW_EVENT_HANDLER_FIELDS).join("|")} (omit for end)`,
197
+ message: `handler must be one of ${Object.keys(WORKFLOW_EVENT_HANDLER_FIELDS).join("|")} (omit for end). 节点级事件不用 handler:改传 activityCode + nodeEvent(${Object.keys(WORKFLOW_NODE_EVENT_HANDLER_FIELDS).join("|")})`,
197
198
  });
198
199
  }
199
200
  return { key: key, field };
200
201
  }
202
+ function resolveBindTarget(handler, nodeEvent, activityCode) {
203
+ const hasNode = nodeEvent !== undefined && nodeEvent !== null;
204
+ const hasActivity = activityCode !== undefined && activityCode !== null && String(activityCode).trim() !== "";
205
+ if (hasNode !== hasActivity) {
206
+ throw new ToolRefusalError({
207
+ status: "refused",
208
+ blocker: "workflow-invalid-handler",
209
+ message: hasNode
210
+ ? `nodeEvent=${String(nodeEvent)} 必须同时给 activityCode(节点级事件写在具体活动上)`
211
+ : `activityCode=${String(activityCode)} 必须同时给 nodeEvent(${Object.keys(WORKFLOW_NODE_EVENT_HANDLER_FIELDS).join("|")});只给 activityCode 不知道要绑该节点的哪个事件`,
212
+ });
213
+ }
214
+ if (hasNode && hasActivity) {
215
+ if (handler !== undefined && handler !== null)
216
+ throw new ToolRefusalError({
217
+ status: "refused",
218
+ blocker: "workflow-invalid-handler",
219
+ message: "handler(流程级)与 activityCode+nodeEvent(节点级)互斥:请只用一种形式",
220
+ });
221
+ const key = String(nodeEvent);
222
+ const field = WORKFLOW_NODE_EVENT_HANDLER_FIELDS[key];
223
+ if (!field)
224
+ throw new ToolRefusalError({
225
+ status: "refused",
226
+ blocker: "workflow-invalid-handler",
227
+ nodeEvent,
228
+ message: `nodeEvent must be one of ${Object.keys(WORKFLOW_NODE_EVENT_HANDLER_FIELDS).join("|")}`,
229
+ });
230
+ return { scope: "activity", key, activityCode: String(activityCode), field };
231
+ }
232
+ const resolved = resolveEventHandlerField(handler);
233
+ return { scope: "workflow", ...resolved };
234
+ }
235
+ /**
236
+ * Activity rows on a workflow template arrive EITHER as an array OR as an
237
+ * index-keyed object (the shape `workflow.get_draft` returns on 1.1.25). Node-
238
+ * level handler edits must preserve whichever shape the draft uses — rebinding a
239
+ * rule must not restructure the caller's stored template.
240
+ */
241
+ function findTemplateActivity(template, activityCode) {
242
+ const raw = template?.activities;
243
+ if (Array.isArray(raw))
244
+ return raw.find((row) => String(row?.activityCode ?? "") === activityCode);
245
+ if (raw && typeof raw === "object")
246
+ return Object.values(raw).find((row) => String(row?.activityCode ?? "") === activityCode);
247
+ return undefined;
248
+ }
249
+ function mapTemplateActivity(template, activityCode, update) {
250
+ const raw = template.activities;
251
+ if (Array.isArray(raw)) {
252
+ let found = false;
253
+ const activities = raw.map((entry) => {
254
+ const row = (entry ?? {});
255
+ if (String(row.activityCode ?? "") !== activityCode)
256
+ return entry;
257
+ found = true;
258
+ return update(row);
259
+ });
260
+ return { found, template: found ? { ...template, activities } : template };
261
+ }
262
+ if (raw && typeof raw === "object") {
263
+ const rows = raw;
264
+ const key = Object.keys(rows).find((name) => String((rows[name] ?? {}).activityCode ?? "") === activityCode);
265
+ if (key === undefined)
266
+ return { found: false, template };
267
+ return { found: true, template: { ...template, activities: { ...rows, [key]: update((rows[key] ?? {})) } } };
268
+ }
269
+ return { found: false, template };
270
+ }
271
+ function templateActivityCodes(template) {
272
+ const raw = template?.activities;
273
+ const rows = Array.isArray(raw) ? raw : raw && typeof raw === "object" ? Object.values(raw) : [];
274
+ return rows.map((row) => String(row?.activityCode ?? "")).filter((code) => code !== "");
275
+ }
201
276
  /**
202
277
  * Live 8.6.26 fidelity — a REFUSED workflow write still answers HTTP 200 with
203
278
  * `errcode 0`; the refusal rides `data` as a `ValidateMessage[]`
@@ -494,7 +569,13 @@ function mapCallerRules(callerRoutes, draftRules, activities) {
494
569
  const record = (entry ?? {});
495
570
  const pre = String(record.preActivityCode ?? record.preNode ?? record.pre ?? record.from ?? "");
496
571
  const post = String(record.postActivityCode ?? record.postNode ?? record.post ?? record.to ?? "");
497
- const points = Array.isArray(record.points) && record.points.length > 0 ? record.points : deriveRulePoints(pre, post, activities);
572
+ // Derive points only when the caller gave none (absent or empty). A
573
+ // MALFORMED value must ride through untouched so validateWorkflowRender
574
+ // refuses it — silently repairing it would hide a shape error.
575
+ const pointsRaw = record.points;
576
+ const points = pointsRaw === undefined || pointsRaw === null || (Array.isArray(pointsRaw) && pointsRaw.length === 0)
577
+ ? deriveRulePoints(pre, post, activities)
578
+ : pointsRaw;
498
579
  const mapped = {
499
580
  ...baseRule,
500
581
  ...Object.fromEntries(Object.entries(record).filter(([key]) => !["preNode", "postNode", "pre", "post", "from", "to"].includes(key))),
@@ -509,13 +590,19 @@ function mapCallerRules(callerRoutes, draftRules, activities) {
509
590
  // silently turn a supplied ruleConditionGroup into a no-op, and both ride in
510
591
  // via the draft baseRule. So force them unless the caller set them itself.
511
592
  if (record.ruleConditionGroup !== undefined) {
512
- if (record.popupType === undefined)
513
- mapped.popupType = "EXPRESSION";
514
- if (record.utilizeElse === undefined)
515
- mapped.utilizeElse = false;
516
- // The platform deserializes BizPropertyType from an INT index; normalize a
517
- // caller's enum name before the wire (see WORKFLOW_BIZ_PROPERTY_TYPE_INDEX).
518
- mapped.ruleConditionGroup = normalizeRuleConditionGroup(record.ruleConditionGroup);
593
+ // The stored spelling for "no condition" is an empty outer array; drop it
594
+ // instead of forcing EXPRESSION/else on a plain edge.
595
+ if (Array.isArray(record.ruleConditionGroup) && record.ruleConditionGroup.length === 0)
596
+ delete mapped.ruleConditionGroup;
597
+ else {
598
+ if (record.popupType === undefined)
599
+ mapped.popupType = "EXPRESSION";
600
+ if (record.utilizeElse === undefined)
601
+ mapped.utilizeElse = false;
602
+ // The platform deserializes BizPropertyType from an INT index; normalize a
603
+ // caller's enum name before the wire (see WORKFLOW_BIZ_PROPERTY_TYPE_INDEX).
604
+ mapped.ruleConditionGroup = normalizeRuleConditionGroup(record.ruleConditionGroup);
605
+ }
519
606
  }
520
607
  return mapped;
521
608
  });
@@ -810,6 +897,18 @@ function validateWorkflowRender(template, tool) {
810
897
  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` });
811
898
  }
812
899
  }
900
+ /** The three accepted spellings of the same edge list; refuse two non-empty ones. */
901
+ function pickRouteSpelling(spellings, tool) {
902
+ const present = Object.entries(spellings).filter(([, value]) => Array.isArray(value) && value.length > 0);
903
+ if (present.length > 1)
904
+ throw new ToolRefusalError({
905
+ status: "refused",
906
+ blocker: "workflow-routes-ambiguous",
907
+ spellings: present.map(([name]) => name),
908
+ message: `${tool}: routes/edges 与 rules 是同一条连线的三种写法(平台 DTO 字段名是 rules;recipe 写法是 routes/edges),本次同时给了 ${present.map(([name]) => name).join(" 和 ")}——只能给一种`,
909
+ });
910
+ return present.length === 1 ? present[0][1] : undefined;
911
+ }
813
912
  /**
814
913
  * The draft rules remapped by index cannot describe an activity the caller
815
914
  * ADDED (an extra approval node): the remapped edges stop at the old chain, an
@@ -856,7 +955,13 @@ export function completeWorkflowChain(rules, activities) {
856
955
  // activities without routes erased A3→A5, every low-amount instance stalled
857
956
  // in PROCESSING with errcode 0, and the only trace was a missing edge in
858
957
  // get_published. Never linearize a graph that already branches.
859
- const branched = [...outbound.values()].some((count) => count > 1);
958
+ //
959
+ // issue 2026-10-22 §1.2: a graph can also fan IN (several approval paths
960
+ // converging on one node). Such a graph has no node with out-degree > 1, so
961
+ // the out-degree-only test missed it and the linear rebuild below compressed
962
+ // 46 edges into 31 (r10/customer 8.6). A merge/shape check must treat BOTH
963
+ // directions as "not a chain": anything with a fork or a join is a graph.
964
+ const branched = [...outbound.values()].some((count) => count > 1) || [...inbound.values()].some((count) => count > 1);
860
965
  if (branched)
861
966
  return rules;
862
967
  const byEdge = new Map(rules.map((rule) => [`${rule.preActivityCode}->${rule.postActivityCode}`, rule]));
@@ -1238,8 +1343,16 @@ async function refuseRequiredOnChildTableFields(session, schemaCode, template, t
1238
1343
  * subset silently REPLACED the whole activity list and dropped nodes.
1239
1344
  */
1240
1345
  function mergeCallerDelta(template, draft, options = {}) {
1241
- const { routes: routesRaw, activities: _raw, edges: edgesRaw, ...rest } = template;
1242
- const routes = routesRaw ?? edgesRaw;
1346
+ const { routes: routesRaw, rules: rulesRaw, activities: _raw, edges: edgesRaw, ...rest } = template;
1347
+ // `routes` and `edges` are the recipe spellings; `rules` is the platform DTO
1348
+ // field name that `get_draft` returns and that a round-tripping caller sends
1349
+ // verbatim. Before this alias, `rules` rode `rest` and was then OVERWRITTEN by
1350
+ // `{...rest, activities, rules}` at the end — a caller that sent `rules` had
1351
+ // its edges silently ignored (issue 2026-10-22 §1.3②). Giving two spellings
1352
+ // with both non-empty is ambiguous; refuse instead of guessing. An EMPTY array
1353
+ // is "not given" (spreading a full template then overriding `rules` is the
1354
+ // documented shape).
1355
+ const routes = pickRouteSpelling({ routes: routesRaw, edges: edgesRaw, rules: rulesRaw }, "workflow.update");
1243
1356
  const draftActivities = Array.isArray(draft.activities) ? draft.activities : [];
1244
1357
  const draftRules = Array.isArray(draft.rules) ? draft.rules : [];
1245
1358
  const caller = (callerActivities(template) ?? []).map((raw) => {
@@ -1328,17 +1441,51 @@ function mergeCallerDelta(template, draft, options = {}) {
1328
1441
  for (const key of ["postActivityCode", "postNode", "post", "to"])
1329
1442
  if (rule[key] !== undefined)
1330
1443
  mapped[key] = remapCode(rule[key]);
1444
+ // Stored drafts on 8.x carry `ruleConditionGroup: []` for a plain edge.
1445
+ // Never echo that empty shape back onto the wire (see the validator note).
1446
+ if (Array.isArray(mapped.ruleConditionGroup) && mapped.ruleConditionGroup.length === 0)
1447
+ delete mapped.ruleConditionGroup;
1331
1448
  return mapped;
1332
1449
  };
1333
1450
  const edgeKey = (rule) => `${rule.preActivityCode ?? rule.preNode ?? rule.pre ?? rule.from}->${rule.postActivityCode ?? rule.postNode ?? rule.post ?? rule.to}`;
1334
1451
  const remappedDraftRules = draftRules.map(remapRuleEndpoints);
1335
- const mappedCallerRoutes = Array.isArray(routes) ? mapCallerRules(routes.map((entry) => remapRuleEndpoints((entry ?? {}))), remappedDraftRules, mergedActivities) : [];
1452
+ const callerGaveRoutes = Array.isArray(routes);
1453
+ const mappedCallerRoutes = callerGaveRoutes ? mapCallerRules(routes.map((entry) => remapRuleEndpoints((entry ?? {}))), remappedDraftRules, mergedActivities) : [];
1336
1454
  const byEdge = new Map();
1337
1455
  for (const rule of remappedDraftRules)
1338
1456
  byEdge.set(edgeKey(rule), rule);
1339
1457
  for (const rule of mappedCallerRoutes)
1340
1458
  byEdge.set(edgeKey(rule), { ...(byEdge.get(edgeKey(rule)) ?? {}), ...rule });
1341
- const mergedRules = completeWorkflowChain([...byEdge.values()], mergedActivities);
1459
+ const unionRules = [...byEdge.values()];
1460
+ // A delta that neither sends routes nor adds a node is an edit of EXISTING
1461
+ // rows — the draft's graph is already the platform's own, so it must ride the
1462
+ // wire verbatim. Running the chain rebuild here is what destroyed the 46-edge
1463
+ // graph (issue 2026-10-22 §1.2: a converging graph failed the chain test and
1464
+ // was replaced by a 31-edge linear chain). Chain completion only makes sense
1465
+ // when the caller actually declares new topology.
1466
+ const needsChainCompletion = callerGaveRoutes || newActivities.length > 0;
1467
+ // Verbatim path: no caller topology and no new node. Return the draft edges
1468
+ // themselves — NOT the by-edge map, which keys on `pre->post` and would
1469
+ // collapse two conditional lines drawn between the same pair of nodes.
1470
+ const mergedRules = needsChainCompletion ? completeWorkflowChain(unionRules, mergedActivities) : remappedDraftRules;
1471
+ // Fail closed on topology CONTRACTION. Merge is a union — it can add edges but
1472
+ // never delete one — so fewer edges than the draft means a rebuild went wrong
1473
+ // (the r10/customer 46→31 incident). Refuse BEFORE the wire and name exactly
1474
+ // which draft edges would disappear, so the caller can re-send routes
1475
+ // explicitly instead of losing them silently.
1476
+ if (mergedRules.length < remappedDraftRules.length) {
1477
+ const kept = new Set(mergedRules.map((rule) => edgeKey(rule)));
1478
+ const dropped = remappedDraftRules.map((rule) => edgeKey(rule)).filter((key) => !kept.has(key));
1479
+ throw new ToolRefusalError({
1480
+ status: "refused",
1481
+ blocker: "workflow-merge-would-drop-routes",
1482
+ workflowCode: String(draft.workflowCode ?? ""),
1483
+ routesBefore: remappedDraftRules.length,
1484
+ routesAfter: mergedRules.length,
1485
+ droppedRoutes: dropped.slice(0, 40),
1486
+ message: `workflow.update merge 会把草稿的连线从 ${remappedDraftRules.length} 条压到 ${mergedRules.length} 条(merge 只会并集、不会删边,减少即工具重算失控)。${dropped.length > 0 ? `缺失连线:${dropped.slice(0, 12).join("、")}${dropped.length > 12 ? `…等 ${dropped.length} 条` : ""}。` : "(多落在同一节点对的多条条件线上,被按节点对合并了。)"}已拒绝写入,草稿未变。改法:要么把要保留的连线随 routes 一起显式发(平台 DTO 名 rules),要么用 merge:false 整模板替换`,
1487
+ });
1488
+ }
1342
1489
  // Same layout + points derivation as the replace path: a chain-completion edge
1343
1490
  // synthesized above carries no points, and the designer canvas needs them.
1344
1491
  const laidOut = canonicalizeWorkflowLayout(mergedActivities, mergedRules);
@@ -1354,7 +1501,9 @@ function normalizeWorkflowTemplate(template, draft, options = {}) {
1354
1501
  if (template === undefined || template === null)
1355
1502
  return {};
1356
1503
  const { routes: routesRaw, activities: rawActivities, edges: edgesRaw, ...rest } = template;
1357
- const routes = routesRaw ?? edgesRaw;
1504
+ const routes = pickRouteSpelling({ routes: routesRaw, edges: edgesRaw, rules: rest.rules }, "workflow.update/publish");
1505
+ if (routes !== undefined)
1506
+ delete rest.rules;
1358
1507
  void rawActivities;
1359
1508
  // Array OR index-keyed object; `rest` must not carry the raw form or an
1360
1509
  // object-shaped activities would ride the wire unmerged.
@@ -1435,6 +1584,27 @@ function normalizeWorkflowTemplate(template, draft, options = {}) {
1435
1584
  * before template as a full-row replace (mergedTemplateBody echoes the draft
1436
1585
  * verbatim when the caller template IS the draft row).
1437
1586
  */
1587
+ /**
1588
+ * Restore-shaped snapshots come from the platform (`get_draft`), so they may
1589
+ * carry spellings the WRITE gate refuses as malformed — notably
1590
+ * `ruleConditionGroup: []` (8.x stores an empty outer array for a plain edge).
1591
+ * Drop it before replaying a rollback: it means "no condition", and the strict
1592
+ * input gate (round7) is right to refuse a caller-authored empty group.
1593
+ */
1594
+ function sanitizeWorkflowSnapshotForReplay(row) {
1595
+ const rules = row.rules;
1596
+ if (!Array.isArray(rules))
1597
+ return row;
1598
+ return {
1599
+ ...row,
1600
+ rules: rules.map((rule) => {
1601
+ const record = { ...(rule ?? {}) };
1602
+ if (Array.isArray(record.ruleConditionGroup) && record.ruleConditionGroup.length === 0)
1603
+ delete record.ruleConditionGroup;
1604
+ return record;
1605
+ }),
1606
+ };
1607
+ }
1438
1608
  function workflowRollbackSpec(toolName) {
1439
1609
  return {
1440
1610
  target: (args) => `workflow:${String(args.schemaCode ?? "")}/${String(args.code ?? args.workflowCode ?? "")}`,
@@ -1460,7 +1630,7 @@ function workflowRollbackSpec(toolName) {
1460
1630
  kind: "calls",
1461
1631
  calls: [
1462
1632
  { tool: "workflow.create", args: { schemaCode, code, name: String(row.workflowName ?? row.name ?? code) } },
1463
- { tool: "workflow.update", args: { schemaCode, code, template: row, merge: false } },
1633
+ { tool: "workflow.update", args: { schemaCode, code, template: sanitizeWorkflowSnapshotForReplay(row), merge: false } },
1464
1634
  ],
1465
1635
  };
1466
1636
  }
@@ -1472,7 +1642,7 @@ function workflowRollbackSpec(toolName) {
1472
1642
  // update / bindRule:整模板替换回 before 草稿
1473
1643
  if (before == null)
1474
1644
  return { kind: "irreversible", reason: "before 快照为空(流程不存在),无内容可恢复" };
1475
- return { kind: "calls", calls: [{ tool: "workflow.update", args: { schemaCode, code, template: before, merge: false } }] };
1645
+ return { kind: "calls", calls: [{ tool: "workflow.update", args: { schemaCode, code, template: sanitizeWorkflowSnapshotForReplay(before), merge: false } }] };
1476
1646
  },
1477
1647
  };
1478
1648
  }
@@ -1593,6 +1763,18 @@ export function workflowCompactProjection(result) {
1593
1763
  // NON-DEFAULT rows (any of visible/editable/required true) so the summary
1594
1764
  // stays small and names exactly what a readback should confirm.
1595
1765
  ...(activityPermissions(activity) ?? {}),
1766
+ // The designer's 「表单操作」 checkboxes live in buttonPermission.customBtns;
1767
+ // surface the enabled codes so a readback answers "which buttons show on
1768
+ // this node" without the 30–75 KB body.
1769
+ ...(() => {
1770
+ const bp = activity.buttonPermission;
1771
+ const btns = Array.isArray(bp?.customBtns) ? bp.customBtns : [];
1772
+ if (btns.length === 0)
1773
+ return {};
1774
+ const enabled = btns.filter((button) => button.enabled === true).map((button) => String(button.code ?? ""));
1775
+ const disabled = btns.filter((button) => button.enabled !== true).map((button) => String(button.code ?? ""));
1776
+ return { formButtons: { ...(enabled.length ? { enabled } : {}), ...(disabled.length ? { disabled } : {}) } };
1777
+ })(),
1596
1778
  }));
1597
1779
  const rules = data.rules;
1598
1780
  const edges = Array.isArray(rules)
@@ -1613,6 +1795,21 @@ export function workflowCompactProjection(result) {
1613
1795
  if (codes.length > 0 && config?.bizActionType === "BIZ_RULE")
1614
1796
  ruleBindings.push({ handler, field, ruleCodes: codes });
1615
1797
  }
1798
+ // Node-level bindings are invisible in the flow-level list above; surface the
1799
+ // non-empty ones so a `get_draft`/`get_published` readback can confirm a
1800
+ // `workflow.bindRule {activityCode,nodeEvent}` landing without `--full`.
1801
+ const nodeRuleBindings = [];
1802
+ for (const row of Object.values(activities)) {
1803
+ const activityCode = String(row?.activityCode ?? "");
1804
+ if (activityCode === "")
1805
+ continue;
1806
+ for (const nodeEvent of Object.keys(WORKFLOW_NODE_EVENT_HANDLER_FIELDS)) {
1807
+ const config = row[nodeEvent];
1808
+ const codes = Array.isArray(config?.bizActions) ? config.bizActions.filter((value) => typeof value === "string") : [];
1809
+ if (codes.length > 0 && config?.bizActionType === "BIZ_RULE")
1810
+ nodeRuleBindings.push({ activityCode, nodeEvent, ruleCodes: codes });
1811
+ }
1812
+ }
1616
1813
  const originate = data.originateActivity;
1617
1814
  return {
1618
1815
  workflowSummary: {
@@ -1621,6 +1818,7 @@ export function workflowCompactProjection(result) {
1621
1818
  activities: nodes,
1622
1819
  edges,
1623
1820
  ruleBindings,
1821
+ ...(nodeRuleBindings.length > 0 ? { nodeRuleBindings } : {}),
1624
1822
  ruleBindingCount: ruleBindings.reduce((sum, binding) => sum + binding.ruleCodes.length, 0),
1625
1823
  ...(originate ? { originateActivity: { code: originate.activityCode ?? null, type: originate.activityType ?? null, name: effectiveActivityName(originate), participant: originate.participant ?? null } } : {}),
1626
1824
  },
@@ -1868,7 +2066,7 @@ export function registerWorkflowLane(tools) {
1868
2066
  readback: { workflowCode: args.code, headerPresent: true, draftPresent: draft != null, templateId: draft?.id ?? null },
1869
2067
  };
1870
2068
  }, undefined, undefined, workflowRollbackSpec("workflow.create"));
1871
- 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) => {
2069
+ 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 强制替换。merge 是并集、绝不删边:结果比草稿少边会被拒(workflow-merge-would-drop-routes);--dry-run 会返回 routesBefore/routesAfter/routesAdded/routesDropped") }, async (session, args) => {
1872
2070
  const template = args.template ?? {};
1873
2071
  validateCallerActivities(template, "workflow.update");
1874
2072
  validateWorkflowRouteConditions(template, "workflow.update");
@@ -1920,7 +2118,66 @@ export function registerWorkflowLane(tools) {
1920
2118
  // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
1921
2119
  // governed pipeline — including the rollback before-capture read.
1922
2120
  assertTemplateNotMinimal(args.template ?? {}, "workflow.update");
1923
- }, undefined, workflowRollbackSpec("workflow.update"));
2121
+ },
2122
+ // Dry-run must actually look at the merge result: shape-only previews said
2123
+ // "wouldChange" while the write silently dropped 15 of 46 edges (issue
2124
+ // 2026-10-22 §1.2). The payload runs the SAME merge as the write (reads
2125
+ // only) and reports the edge delta; the contraction refusal fires here too.
2126
+ async (session, args) => {
2127
+ const template = args.template ?? {};
2128
+ const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
2129
+ const draft = await getWorkflowDraftOrNull(session, args.code);
2130
+ if (draft === null)
2131
+ throw new ToolRefusalError({
2132
+ status: "refused",
2133
+ blocker: "workflow-not-found",
2134
+ workflowCode: args.code,
2135
+ message: `流程 ${args.code} 不存在(get_draft 401001 缺席)——先 workflow.create 再 update/publish`,
2136
+ });
2137
+ const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
2138
+ validateWorkflowRender(body, "workflow.update");
2139
+ const edgeKeys = (rules) => (Array.isArray(rules) ? rules : []).map((rule) => `${String(rule.preActivityCode ?? "")}->${String(rule.postActivityCode ?? "")}`);
2140
+ const beforeEdges = edgeKeys(draft.rules);
2141
+ const afterEdges = edgeKeys(body.rules);
2142
+ // Multiset difference: two conditional lines can share one node pair, so a
2143
+ // Set would hide a real drop.
2144
+ const remaining = [...afterEdges];
2145
+ const routesDropped = [];
2146
+ for (const edge of beforeEdges) {
2147
+ const index = remaining.indexOf(edge);
2148
+ if (index >= 0)
2149
+ remaining.splice(index, 1);
2150
+ else
2151
+ routesDropped.push(edge);
2152
+ }
2153
+ const beforeLeft = [...beforeEdges];
2154
+ const routesAdded = [];
2155
+ for (const edge of afterEdges) {
2156
+ const index = beforeLeft.indexOf(edge);
2157
+ if (index >= 0)
2158
+ beforeLeft.splice(index, 1);
2159
+ else
2160
+ routesAdded.push(edge);
2161
+ }
2162
+ const activityCodes = (rows) => {
2163
+ const list = Array.isArray(rows) ? rows : rows && typeof rows === "object" ? Object.values(rows) : [];
2164
+ return list.map((row) => String(row?.activityCode ?? "")).filter((code) => code !== "");
2165
+ };
2166
+ const callerSentRoutes = template.routes !== undefined || template.edges !== undefined || template.rules !== undefined;
2167
+ return {
2168
+ merge,
2169
+ callerSentRoutes,
2170
+ activitiesBefore: activityCodes(draft.activities).length,
2171
+ activitiesAfter: activityCodes(body.activities).length,
2172
+ routesBefore: beforeEdges.length,
2173
+ routesAfter: afterEdges.length,
2174
+ routesAdded,
2175
+ routesDropped,
2176
+ hint: callerSentRoutes
2177
+ ? "本次显式给了连线:merge 是并集(草稿边 + 你的边),不会删边"
2178
+ : "本次未给 routes/edges/rules:草稿连线原样沿用(新增节点才会自动接链)",
2179
+ };
2180
+ }, workflowRollbackSpec("workflow.update"));
1924
2181
  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) => {
1925
2182
  // An absent template publishes the stored draft as-is — the platform's own
1926
2183
  // path. The schema is optional so the contract no longer contradicts it.
@@ -1984,10 +2241,19 @@ export function registerWorkflowLane(tools) {
1984
2241
  handler: z
1985
2242
  .string()
1986
2243
  .optional()
1987
- .describe("event handler to bind: start | cancel | activate | end (default end)"),
2244
+ .describe("FLOW-level event handler to bind: start | cancel | activate | end (default end). 与 activityCode+nodeEvent 互斥"),
2245
+ activityCode: z
2246
+ .string()
2247
+ .min(1)
2248
+ .optional()
2249
+ .describe("NODE-level target activity (草稿里的 activityCode,如 Activity2). 必须与 nodeEvent 同时给;节点不存在时报 workflow-activity-not-found 并列出可用 code"),
2250
+ nodeEvent: z
2251
+ .enum(["beforeActivate", "afterActivate", "endActivity", "cancelActivity", "activityApproved", "activityRejected", "jobSubmitted", "jobRejected"])
2252
+ .optional()
2253
+ .describe("NODE-level event on activityCode: beforeActivate|afterActivate|endActivity|cancelActivity|activityApproved|activityRejected|jobSubmitted|jobRejected(如填写节点 endActivity = 申请人提交离开该节点时). 与 handler 互斥"),
1988
2254
  publish: z.boolean().optional(),
1989
2255
  }, async (session, args) => {
1990
- const { key: handlerKey, field: handlerField } = resolveEventHandlerField(args.handler);
2256
+ const target = resolveBindTarget(args.handler, args.nodeEvent, args.activityCode);
1991
2257
  const codes = (args.ruleCodes ?? [args.ruleCode]).map(String);
1992
2258
  // Only CUSTOM rules are bindable to workflow events — the designer's picker
1993
2259
  // does not offer preset trigger rows (bench59 bound `Create` to `end`).
@@ -2005,7 +2271,25 @@ export function registerWorkflowLane(tools) {
2005
2271
  message: `流程 ${args.workflowCode} 不存在(get_draft 401001 缺席)——先 workflow.create,再 bindRule`,
2006
2272
  });
2007
2273
  }
2008
- const existing = (draft[handlerField] ?? {});
2274
+ // Node-level: fail closed BEFORE the wire when the activity code is not in
2275
+ // the draft, and name the codes that ARE (the designer's own codes like
2276
+ // Activity2 are the common miss).
2277
+ if (target.scope === "activity" && !findTemplateActivity(draft, target.activityCode)) {
2278
+ throw new ToolRefusalError({
2279
+ status: "refused",
2280
+ blocker: "workflow-activity-not-found",
2281
+ workflowCode: args.workflowCode,
2282
+ activityCode: target.activityCode,
2283
+ activities: templateActivityCodes(draft),
2284
+ message: `流程 ${args.workflowCode} 的草稿里没有活动 ${target.activityCode}——节点级事件必须指向已存在的活动。可用活动:${templateActivityCodes(draft).join("、") || "(无)"}(先用 workflow.get_draft 看 code,别用节点名)`,
2285
+ });
2286
+ }
2287
+ const handlerOf = (template) => {
2288
+ if (target.scope === "workflow")
2289
+ return (template?.[target.field] ?? {}) ?? {};
2290
+ return (findTemplateActivity(template, target.activityCode)?.[target.field] ?? {}) ?? {};
2291
+ };
2292
+ const existing = handlerOf(draft);
2009
2293
  const priorActions = Array.isArray(existing.bizActions)
2010
2294
  ? existing.bizActions.filter((value) => typeof value === "string")
2011
2295
  : [];
@@ -2016,16 +2300,21 @@ export function registerWorkflowLane(tools) {
2016
2300
  for (const code of codes)
2017
2301
  if (!nextActions.includes(code))
2018
2302
  nextActions.push(code);
2019
- const nextTemplate = {
2020
- ...draft,
2021
- [handlerField]: {
2022
- ...(typeof existing === "object" && existing ? existing : {}),
2023
- bizActions: nextActions,
2024
- bizActionType: "BIZ_RULE",
2025
- },
2026
- workflowCode: args.workflowCode,
2027
- schemaCode: args.schemaCode,
2303
+ const mergedHandler = {
2304
+ ...(typeof existing === "object" && existing ? existing : {}),
2305
+ bizActions: nextActions,
2306
+ bizActionType: "BIZ_RULE",
2028
2307
  };
2308
+ let nextTemplate;
2309
+ if (target.scope === "workflow") {
2310
+ nextTemplate = { ...draft, [target.field]: mergedHandler, workflowCode: args.workflowCode, schemaCode: args.schemaCode };
2311
+ }
2312
+ else {
2313
+ // Keep the draft's activities container shape (array or index-keyed
2314
+ // object) — only the selected row's handler field changes.
2315
+ nextTemplate = mapTemplateActivity(draft, target.activityCode, (row) => ({ ...row, [target.field]: mergedHandler })).template;
2316
+ nextTemplate = { ...nextTemplate, workflowCode: args.workflowCode, schemaCode: args.schemaCode };
2317
+ }
2029
2318
  validateWorkflowRender(nextTemplate, "workflow.bindRule");
2030
2319
  const performed = await wireCall(session, {
2031
2320
  path: ENDPOINTS.workflowUpdate,
@@ -2052,18 +2341,21 @@ export function registerWorkflowLane(tools) {
2052
2341
  if (!data)
2053
2342
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.bindRule returned no draft" });
2054
2343
  validateWorkflowRender(data, "workflow.bindRule readback");
2055
- const handler = data ? data[handlerField] : undefined;
2344
+ const handler = handlerOf(data);
2056
2345
  const bizActions = Array.isArray(handler?.bizActions) ? handler.bizActions.filter((value) => typeof value === "string") : [];
2057
- const publishedHandler = publishedReadback ? publishedReadback[handlerField] : undefined;
2346
+ const publishedHandler = publishedReadback ? handlerOf(publishedReadback) : undefined;
2058
2347
  const publishedBizActions = Array.isArray(publishedHandler?.bizActions)
2059
2348
  ? publishedHandler.bizActions.filter((value) => typeof value === "string")
2060
2349
  : [];
2350
+ const targetShape = target.scope === "workflow"
2351
+ ? { handler: target.key, handlerField: target.field }
2352
+ : { activityCode: target.activityCode, nodeEvent: target.key, nodeEventField: target.field };
2061
2353
  return {
2062
- performed: { errcode: performed.errcode, handler: handlerKey, addedRuleCodes: codes, bizActions, publishRequested: shouldPublish },
2354
+ performed: { errcode: performed.errcode, scope: target.scope, ...targetShape, addedRuleCodes: codes, bizActions, publishRequested: shouldPublish },
2063
2355
  readback: {
2064
2356
  workflowCode: args.workflowCode,
2065
- handler: handlerKey,
2066
- handlerField,
2357
+ scope: target.scope,
2358
+ ...targetShape,
2067
2359
  semantics: "merge",
2068
2360
  bound: codes.every((code) => bizActions.includes(code)),
2069
2361
  bizActions,
@@ -2071,7 +2363,9 @@ export function registerWorkflowLane(tools) {
2071
2363
  enabledRuleCodes,
2072
2364
  alreadyEnabledRuleCodes,
2073
2365
  bizActionType: typeof handler?.bizActionType === "string" ? handler.bizActionType : null,
2074
- templateFieldsPreserved: Object.keys(draft).every((key) => key === handlerField || data[key] !== undefined),
2366
+ templateFieldsPreserved: target.scope === "workflow"
2367
+ ? Object.keys(draft).every((key) => key === target.field || data[key] !== undefined)
2368
+ : Object.keys(draft).every((key) => data[key] !== undefined),
2075
2369
  // Authoritative published-face marker, NOT the request echo: a
2076
2370
  // refused publish keeps errcode 0 and the draft marker (live 8.6.26).
2077
2371
  published: publishMarked,
@@ -2086,7 +2380,7 @@ export function registerWorkflowLane(tools) {
2086
2380
  },
2087
2381
  };
2088
2382
  }, (args) => {
2089
- resolveEventHandlerField(args.handler);
2383
+ resolveBindTarget(args.handler, args.nodeEvent, args.activityCode);
2090
2384
  const hasSingle = typeof args.ruleCode === "string" && args.ruleCode.length > 0;
2091
2385
  const hasMany = Array.isArray(args.ruleCodes);
2092
2386
  if (hasSingle && hasMany)
@@ -2099,6 +2393,130 @@ export function registerWorkflowLane(tools) {
2099
2393
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "ruleCodes 必须是非空、去重的规则 code 数组" });
2100
2394
  }
2101
2395
  }, undefined, workflowRollbackSpec("workflow.bindRule"));
2396
+ /**
2397
+ * Friendly writer for the designer's 「表单操作」 checkboxes: each PARTICIPANT
2398
+ * activity carries `buttonPermission.customBtns[]` = the form-toolbar buttons
2399
+ * enabled at that node (ActionPermissionHandler.setProcessButtonPermission
2400
+ * filters `getByTriggerTypeAndCode(FORM, formCode)` by this list; a code that
2401
+ * is absent or enabled:false does not render). Writing the whole
2402
+ * `buttonPermission` object by hand means re-sending containerCode +
2403
+ * childTableBtns, which is exactly how a caller drops the child-table
2404
+ * defaults — this tool merges per node.
2405
+ */
2406
+ writeTool(tools, "workflow.setButtons", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.setButtons"].scope, WORKFLOW_TOOLS["workflow.setButtons"].description, {
2407
+ schemaCode: z.string().min(1),
2408
+ workflowCode: z.string().min(1),
2409
+ nodes: z
2410
+ .array(z.object({
2411
+ activityCode: z.string().min(1).describe("活动 code(workflow.get_draft 的 activities[].code)"),
2412
+ buttonCodes: z.array(z.string().min(1)).describe("该节点要**启用**的表单按钮 code(按 form.get 的 formButtonCodes);未列出的置为 enabled:false,不删除子表按钮配置"),
2413
+ }))
2414
+ .min(1),
2415
+ publish: z.boolean().optional().describe("默认 true:写草稿后发布,表单页立即生效;false 只写草稿"),
2416
+ }, async (session, args) => {
2417
+ const draft = (await getWorkflowDraftOrNull(session, args.workflowCode)) ?? {};
2418
+ if (Object.keys(draft).length === 0)
2419
+ throw new ToolRefusalError({
2420
+ status: "refused",
2421
+ blocker: "workflow-not-found",
2422
+ workflowCode: args.workflowCode,
2423
+ message: `流程 ${args.workflowCode} 不存在(get_draft 401001 缺席)`,
2424
+ });
2425
+ const available = await readSchemaFormButtons(session, args.schemaCode);
2426
+ const availableCodes = Object.keys(available);
2427
+ const draftCodes = templateActivityCodes(draft);
2428
+ const patches = [];
2429
+ const names = {};
2430
+ for (const node of args.nodes) {
2431
+ const row = findTemplateActivity(draft, node.activityCode);
2432
+ if (!row) {
2433
+ throw new ToolRefusalError({
2434
+ status: "refused",
2435
+ blocker: "workflow-activity-not-found",
2436
+ workflowCode: args.workflowCode,
2437
+ activityCode: node.activityCode,
2438
+ activities: draftCodes,
2439
+ message: `流程 ${args.workflowCode} 没有活动 ${node.activityCode};可用:${draftCodes.join("、") || "(无)"}`,
2440
+ });
2441
+ }
2442
+ const unknown = node.buttonCodes.filter((code) => available[code] === undefined);
2443
+ if (unknown.length > 0) {
2444
+ throw new ToolRefusalError({
2445
+ status: "refused",
2446
+ blocker: "form-button-absent",
2447
+ schemaCode: args.schemaCode,
2448
+ activityCode: node.activityCode,
2449
+ buttonCodes: unknown,
2450
+ available: availableCodes,
2451
+ message: `按钮 ${unknown.join("、")} 不在模型 ${args.schemaCode} 的表单按钮里;先 form.publish {buttons:[…]} 生成。可用:${availableCodes.join("、") || "(无)"}`,
2452
+ });
2453
+ }
2454
+ const existing = (row.buttonPermission ?? {});
2455
+ const existingBtns = Array.isArray(existing.customBtns) ? existing.customBtns : [];
2456
+ const wanted = new Set(node.buttonCodes);
2457
+ const merged = [];
2458
+ for (const button of existingBtns) {
2459
+ const code = String(button.code ?? "");
2460
+ if (code === "")
2461
+ continue;
2462
+ merged.push({ ...button, enabled: wanted.has(code) });
2463
+ wanted.delete(code);
2464
+ }
2465
+ for (const code of wanted) {
2466
+ const source = available[code];
2467
+ names[code] = String(source.name ?? code);
2468
+ merged.push({ code, name: String(source.name ?? code), name_i18n: source.name_i18n ?? null, enabled: true, type: "form" });
2469
+ }
2470
+ patches.push({
2471
+ activityCode: node.activityCode,
2472
+ buttonPermission: {
2473
+ ...existing,
2474
+ containerCode: existing.containerCode ?? args.schemaCode,
2475
+ customBtns: merged,
2476
+ childTableBtns: existing.childTableBtns ?? [],
2477
+ },
2478
+ });
2479
+ }
2480
+ const body = await mergedTemplateBody(session, args.schemaCode, args.workflowCode, { activities: patches }, true);
2481
+ validateWorkflowRender(body, "workflow.setButtons");
2482
+ const performed = await wireCall(session, { path: ENDPOINTS.workflowUpdate, method: "POST", body });
2483
+ const shouldPublish = args.publish !== false;
2484
+ let publishMarked = false;
2485
+ if (shouldPublish) {
2486
+ const publishPerformed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
2487
+ assertWorkflowWriteAccepted(publishPerformed, "workflow.setButtons", args.workflowCode);
2488
+ const pub = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.workflowCode } });
2489
+ const published = (pub.data ?? null);
2490
+ publishMarked = published !== null && (published.templateType === "PUBLISH" || published.published === true);
2491
+ }
2492
+ const readback = (await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.workflowCode } })).data;
2493
+ const nodeButtons = (readback ? readback.activities : draft.activities);
2494
+ const rows = Array.isArray(nodeButtons)
2495
+ ? nodeButtons
2496
+ : nodeButtons && typeof nodeButtons === "object"
2497
+ ? Object.values(nodeButtons)
2498
+ : [];
2499
+ const state = args.nodes.map((node) => {
2500
+ const row = rows.find((entry) => String(entry.activityCode ?? "") === node.activityCode) ?? {};
2501
+ const bp = (row.buttonPermission ?? {});
2502
+ const btns = Array.isArray(bp.customBtns) ? bp.customBtns : [];
2503
+ return {
2504
+ activityCode: node.activityCode,
2505
+ enabled: btns.filter((button) => button.enabled === true).map((button) => String(button.code ?? "")),
2506
+ disabled: btns.filter((button) => button.enabled !== true).map((button) => String(button.code ?? "")),
2507
+ };
2508
+ });
2509
+ return {
2510
+ performed: { errcode: performed.errcode, publishRequested: shouldPublish, nodes: args.nodes.map((node) => node.activityCode) },
2511
+ readback: {
2512
+ workflowCode: args.workflowCode,
2513
+ nodes: state,
2514
+ published: publishMarked,
2515
+ buttonNames: names,
2516
+ note: "节点未列出的按钮被置为 enabled:false(不删除子表按钮配置);表单页只在 enabled:true 的节点显示该按钮",
2517
+ },
2518
+ };
2519
+ }, undefined, undefined, workflowRollbackSpec("workflow.update"));
2102
2520
  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) => {
2103
2521
  const performed = await wireCall(session, {
2104
2522
  path: ENDPOINTS.workflowDelete,