@stalfh233/omc-cli 0.4.74 → 0.4.76
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.
- package/coverage/m1-coverage-manifest-v1.json +9 -3
- package/dist/artifacts.js +2 -2
- package/dist/capability-overview.js +12 -3
- package/dist/cli.js +105 -39
- package/dist/command-transport.js +1 -1
- package/dist/compute.js +2 -1
- package/dist/discovery.js +18 -0
- package/dist/endpoints.js +24 -0
- package/dist/form-default.js +97 -10
- package/dist/form-layout.js +25 -2
- package/dist/form-support.js +361 -10
- package/dist/human-summary.js +4 -0
- package/dist/lanes/businessrule.js +7 -0
- package/dist/lanes/datarule.js +33 -9
- package/dist/lanes/form-contract.js +14 -4
- package/dist/lanes/form-coverage.js +2 -1
- package/dist/lanes/form.js +44 -1
- package/dist/lanes/organization.js +37 -3
- package/dist/lanes/rule-lifecycle-contract.js +17 -0
- package/dist/lanes/rule-lifecycle-coverage.js +2 -0
- package/dist/lanes/rule-lifecycle.js +103 -19
- package/dist/lanes/workflow-contract.js +57 -10
- package/dist/lanes/workflow-coverage.js +4 -0
- package/dist/lanes/workflow.js +458 -40
- package/dist/path-projection.js +129 -0
- package/dist/rollback-spec.js +32 -1
- package/dist/usage-log.js +11 -2
- package/dist/write.js +9 -3
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +20 -2
- package/skills/omc-capabilities.md +8 -7
- package/skills/omc-write-hazards.md +4 -4
package/dist/lanes/workflow.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
if (record.
|
|
515
|
-
mapped.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
},
|
|
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
|
|
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
|
-
|
|
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
|
|
2020
|
-
...
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
|
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
|
|
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,
|
|
2354
|
+
performed: { errcode: performed.errcode, scope: target.scope, ...targetShape, addedRuleCodes: codes, bizActions, publishRequested: shouldPublish },
|
|
2063
2355
|
readback: {
|
|
2064
2356
|
workflowCode: args.workflowCode,
|
|
2065
|
-
|
|
2066
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|