@stalfh233/omc-cli 0.6.6 → 0.6.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 (68) hide show
  1. package/README.md +2 -1
  2. package/dist/approval-flow.js +5 -3
  3. package/dist/bizservice-config.js +26 -10
  4. package/dist/call.js +47 -0
  5. package/dist/capability-overview.js +47 -24
  6. package/dist/cli-contract.js +90 -17
  7. package/dist/cli-output.js +41 -2
  8. package/dist/cli-stdout.js +74 -0
  9. package/dist/cli.js +162 -54
  10. package/dist/command-transport.js +8 -1
  11. package/dist/config-store.js +17 -2
  12. package/dist/discovery.js +62 -3
  13. package/dist/field-families.js +35 -6
  14. package/dist/goal-routes.js +118 -11
  15. package/dist/human-summary.js +11 -0
  16. package/dist/lanes/app-contract.js +12 -5
  17. package/dist/lanes/app.js +221 -134
  18. package/dist/lanes/apply-changes.js +2 -1
  19. package/dist/lanes/b2-registration-contract.js +3 -3
  20. package/dist/lanes/b2-registration.js +890 -693
  21. package/dist/lanes/businessrule.js +75 -51
  22. package/dist/lanes/custom-page.js +166 -115
  23. package/dist/lanes/datarule-contract.js +2 -2
  24. package/dist/lanes/datarule.js +238 -154
  25. package/dist/lanes/dictionary.js +253 -110
  26. package/dist/lanes/environment.js +57 -9
  27. package/dist/lanes/field.js +160 -107
  28. package/dist/lanes/folder-contract.js +7 -3
  29. package/dist/lanes/folder.js +249 -118
  30. package/dist/lanes/form-contract.js +1 -1
  31. package/dist/lanes/form.js +188 -64
  32. package/dist/lanes/list-view.js +405 -369
  33. package/dist/lanes/meta.js +31 -6
  34. package/dist/lanes/model-contract.js +1 -1
  35. package/dist/lanes/model.js +610 -512
  36. package/dist/lanes/online-js-contract.js +1 -1
  37. package/dist/lanes/online-js.js +160 -146
  38. package/dist/lanes/organization.js +905 -640
  39. package/dist/lanes/programming.js +274 -136
  40. package/dist/lanes/report-contract.js +2 -2
  41. package/dist/lanes/report.js +354 -198
  42. package/dist/lanes/rule-lifecycle.js +294 -140
  43. package/dist/lanes/runtime-data-contract.js +4 -4
  44. package/dist/lanes/runtime-data.js +869 -745
  45. package/dist/lanes/workflow-contract.js +3 -3
  46. package/dist/lanes/workflow.js +732 -605
  47. package/dist/model-scaffold.js +26 -5
  48. package/dist/path-projection.js +196 -14
  49. package/dist/plan.js +25 -0
  50. package/dist/programming-workflow.js +55 -3
  51. package/dist/read-shapes.js +146 -0
  52. package/dist/read.js +40 -9
  53. package/dist/recipes/businessrule.js +74 -24
  54. package/dist/recipes/dictionary.js +7 -4
  55. package/dist/recipes/form.js +46 -0
  56. package/dist/recipes/runtime-data.js +3 -2
  57. package/dist/refusal-guidance.js +29 -5
  58. package/dist/rule-support.js +14 -1
  59. package/dist/rule-when.js +2 -2
  60. package/dist/tool-types.js +9 -3
  61. package/dist/version-family-gaps.js +101 -0
  62. package/dist/write.js +2 -1
  63. package/package.json +6 -4
  64. package/skills/omc-glossary.md +13 -0
  65. package/skills/omc-refusals.md +13 -5
  66. package/skills/omc-start-here.md +2 -2
  67. package/skills/omc-tool-selection.md +2 -1
  68. package/skills/omc-write-hazards.md +5 -2
@@ -2226,386 +2226,425 @@ export function registerWorkflowLane(tools) {
2226
2226
  return { workflowCode, valid: errors.length === 0, errorCount: errors.length, warningCount: warnings.length, errors, warnings };
2227
2227
  }, { target: `workflow.validate:${args.workflowCode ?? args.schemaCode}` }),
2228
2228
  }));
2229
- writeTool(tools, "workflow.create", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.create"].scope, WORKFLOW_TOOLS["workflow.create"].description, {
2230
- schemaCode: z.string().min(1),
2231
- // 2026-10-27 用户需求:流程名默认与表单名一致。省略时的解析链见
2232
- // resolveWorkflowName——表单名 → 模型名 → 拒绝(不拿 schemaCode 冒充)。
2233
- name: z.string().min(1).optional().describe("流程名称;省略时默认 = 该模型默认表单的名字(无表单草稿时用模型名),与设计器行为一致"),
2234
- code: z.string().min(1),
2235
- }, async (session, args) => {
2236
- // Ticket 76 idempotent retry: get_header FIRST — a post-timeout
2237
- // re-create of the same code short-circuits with the existing header
2238
- // and ZERO create wires. On the happy path this read REPLACES the
2239
- // post-create header read (same wire count), because a successful
2240
- // create implies the header.
2241
- const existingHeader = await getWorkflowHeaderOrNull(session, args.code);
2242
- if (existingHeader != null) {
2243
- return { performed: { skipped: "already-present" }, readback: { alreadyPresent: true, header: existingHeader } };
2244
- }
2245
- const { name: workflowName, resolvedFrom } = await resolveWorkflowName(session, args.name, args.schemaCode);
2246
- let performed;
2247
- try {
2248
- performed = await wireCall(session, {
2249
- path: ENDPOINTS.workflowCreate,
2250
- method: "POST",
2251
- body: { workflowCode: args.code, workflowName, schemaCode: args.schemaCode, icon: "h-icon-all-logic" },
2252
- });
2253
- }
2254
- catch (error) {
2255
- // Ticket 76 live: the platform answers an envelope with NO numeric
2256
- // errcode when the SCHEMA already has a workflow (a second, differently
2257
- // coded one is refused). Decode it as a duplicate-create — same-code
2258
- // presence was already excluded by the pre-check, so this is the
2259
- // different-code case — instead of surfacing a malformed-envelope
2260
- // transport-looking failure on a business duplicate.
2261
- if (error instanceof WireError && error.kind === "malformed-envelope") {
2262
- const has = await wireCall(session, { path: ENDPOINTS.workflowIsWorkflow, query: { schemaCode: args.schemaCode } });
2263
- if (has.data === true) {
2264
- throw new ToolRefusalError({
2265
- status: "refused",
2266
- blocker: "workflow-already-exists",
2267
- schemaCode: args.schemaCode,
2268
- workflowCode: args.code,
2269
- message: `模型 ${args.schemaCode} 已有流程(平台对重复创建返回非数字 errcode 的 envelope)——一个模型只能有一个流程。要改现有流程用 workflow.update/workflow.publish(先 workflow.get_draft 取当前模板),不要另建新 code`,
2270
- });
2229
+ writeTool({
2230
+ tools,
2231
+ name: "workflow.create",
2232
+ lane: WORKFLOW_LANE,
2233
+ scope: WORKFLOW_TOOLS["workflow.create"].scope,
2234
+ description: WORKFLOW_TOOLS["workflow.create"].description,
2235
+ inputSchema: {
2236
+ schemaCode: z.string().min(1),
2237
+ // 2026-10-27 用户需求:流程名默认与表单名一致。省略时的解析链见
2238
+ // resolveWorkflowName——表单名 → 模型名 → 拒绝(不拿 schemaCode 冒充)。
2239
+ name: z.string().min(1).optional().describe("流程名称;省略时默认 = 该模型默认表单的名字(无表单草稿时用模型名),与设计器行为一致"),
2240
+ code: z.string().min(1),
2241
+ },
2242
+ execute: async (session, args) => {
2243
+ // Ticket 76 idempotent retry: get_header FIRST — a post-timeout
2244
+ // re-create of the same code short-circuits with the existing header
2245
+ // and ZERO create wires. On the happy path this read REPLACES the
2246
+ // post-create header read (same wire count), because a successful
2247
+ // create implies the header.
2248
+ const existingHeader = await getWorkflowHeaderOrNull(session, args.code);
2249
+ if (existingHeader != null) {
2250
+ return { performed: { skipped: "already-present" }, readback: { alreadyPresent: true, header: existingHeader } };
2251
+ }
2252
+ const { name: workflowName, resolvedFrom } = await resolveWorkflowName(session, args.name, args.schemaCode);
2253
+ let performed;
2254
+ try {
2255
+ performed = await wireCall(session, {
2256
+ path: ENDPOINTS.workflowCreate,
2257
+ method: "POST",
2258
+ body: { workflowCode: args.code, workflowName, schemaCode: args.schemaCode, icon: "h-icon-all-logic" },
2259
+ });
2260
+ }
2261
+ catch (error) {
2262
+ // Ticket 76 live: the platform answers an envelope with NO numeric
2263
+ // errcode when the SCHEMA already has a workflow (a second, differently
2264
+ // coded one is refused). Decode it as a duplicate-create — same-code
2265
+ // presence was already excluded by the pre-check, so this is the
2266
+ // different-code case — instead of surfacing a malformed-envelope
2267
+ // transport-looking failure on a business duplicate.
2268
+ if (error instanceof WireError && error.kind === "malformed-envelope") {
2269
+ const has = await wireCall(session, { path: ENDPOINTS.workflowIsWorkflow, query: { schemaCode: args.schemaCode } });
2270
+ if (has.data === true) {
2271
+ throw new ToolRefusalError({
2272
+ status: "refused",
2273
+ blocker: "workflow-already-exists",
2274
+ schemaCode: args.schemaCode,
2275
+ workflowCode: args.code,
2276
+ message: `模型 ${args.schemaCode} 已有流程(平台对重复创建返回非数字 errcode 的 envelope)——一个模型只能有一个流程。要改现有流程用 workflow.update/workflow.publish(先 workflow.get_draft 取当前模板),不要另建新 code`,
2277
+ });
2278
+ }
2271
2279
  }
2280
+ throw error;
2272
2281
  }
2273
- throw error;
2274
- }
2275
- const draft = await getWorkflowDraftOrNull(session, args.code);
2276
- return {
2277
- performed: { errcode: performed.errcode },
2278
- readback: { workflowCode: args.code, name: workflowName, nameResolvedFrom: resolvedFrom, headerPresent: true, draftPresent: draft != null, templateId: draft?.id ?? null },
2279
- };
2280
- }, undefined, undefined, workflowRollbackSpec("workflow.create"));
2281
- 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).describe("流程编码——本参数名是 code,传 workflowCode 会被 schema 拒绝"), 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) => {
2282
- const template = args.template ?? {};
2283
- validateCallerActivities(template, "workflow.update");
2284
- validateWorkflowRouteConditions(template, "workflow.update");
2285
- assertTemplateNotMinimal(template, "workflow.update");
2286
- lintWorkflowTemplate(template, "workflow.update");
2287
- await refuseRequiredOnChildTableFields(session, args.schemaCode, template, "workflow.update");
2288
- // Ticket 112: merge onto the full draft row — a verbatim minimal post
2289
- // leaves converter holes and answers errcode 50000 on the platform.
2290
- // Delta semantics: explicit `true` forces merge, explicit `false` forces
2291
- // replace, and the default auto-detects a partial template (missing
2292
- // START/END) as a delta — see activitiesLackBoundaries.
2293
- const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
2294
- const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
2295
- await backfillDesignerDefaults(session, args.schemaCode, body, template);
2296
- assertFirstActivityIsOriginator(template, "workflow.update", body);
2297
- validateWorkflowRender(body, "workflow.update");
2298
- await assertRoleParticipantScopes(session, body);
2299
- // Live: workflow/update does NOT run the template validator — it stores a
2300
- // refused participant silently and only /publish answers the
2301
- // ValidateMessage[]. So the guard belongs on publish/bindRule, not here.
2302
- const performed = await wireCall(session, { path: ENDPOINTS.workflowUpdate, method: "POST", body });
2303
- const draft = await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.code } });
2304
- const data = (draft.data ?? null);
2305
- if (!data)
2306
- throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.update returned no draft" });
2307
- validateWorkflowRender(data, "workflow.update readback");
2308
- const participantWarnings = participantReferenceWarnings(data);
2309
- // Evaluate on `body` (post-backfill): the readback may drop the handler
2310
- // keys, and the backfilled permittedAction is exactly what will run.
2311
- const rejectWarnings = rejectToStartCancelWarnings(body);
2312
- const renamedActivities = renamedActivityPairs(template, data);
2313
- const renamedInPlace = inPlaceRenames(data);
2314
- // Caller sent no routes at all: the draft's edges (branches included) are
2315
- // what rides the wire. Say so, and name the branch edges, because the only
2316
- // other trace is a missing edge in a later getPublished readback.
2317
- const callerSentRoutes = template.routes !== undefined || template.edges !== undefined || template.rules !== undefined;
2318
- const branchEdges = callerSentRoutes ? [] : inheritedBranchEdges(data);
2319
- // 改名验收的权威读数:每个节点的当前显示名(platform may keep
2320
- // activityName or sync it; effectiveActivityName resolves both shapes).
2321
- const rawActivityRows = Array.isArray(data.activities)
2322
- ? data.activities
2323
- : data.activities && typeof data.activities === "object"
2324
- ? Object.values(data.activities)
2282
+ const draft = await getWorkflowDraftOrNull(session, args.code);
2283
+ return {
2284
+ performed: { errcode: performed.errcode },
2285
+ readback: { workflowCode: args.code, name: workflowName, nameResolvedFrom: resolvedFrom, headerPresent: true, draftPresent: draft != null, templateId: draft?.id ?? null },
2286
+ };
2287
+ },
2288
+ rollback: workflowRollbackSpec("workflow.create"),
2289
+ });
2290
+ writeTool({
2291
+ tools,
2292
+ name: "workflow.update",
2293
+ lane: WORKFLOW_LANE,
2294
+ scope: WORKFLOW_TOOLS["workflow.update"].scope,
2295
+ description: WORKFLOW_TOOLS["workflow.update"].description,
2296
+ inputSchema: { schemaCode: z.string().min(1), code: z.string().min(1).describe("流程编码——本参数名是 code,传 workflowCode 会被 schema 拒绝"), 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") },
2297
+ execute: async (session, args) => {
2298
+ const template = args.template ?? {};
2299
+ validateCallerActivities(template, "workflow.update");
2300
+ validateWorkflowRouteConditions(template, "workflow.update");
2301
+ assertTemplateNotMinimal(template, "workflow.update");
2302
+ lintWorkflowTemplate(template, "workflow.update");
2303
+ await refuseRequiredOnChildTableFields(session, args.schemaCode, template, "workflow.update");
2304
+ // Ticket 112: merge onto the full draft row — a verbatim minimal post
2305
+ // leaves converter holes and answers errcode 50000 on the platform.
2306
+ // Delta semantics: explicit `true` forces merge, explicit `false` forces
2307
+ // replace, and the default auto-detects a partial template (missing
2308
+ // START/END) as a delta — see activitiesLackBoundaries.
2309
+ const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
2310
+ const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
2311
+ await backfillDesignerDefaults(session, args.schemaCode, body, template);
2312
+ assertFirstActivityIsOriginator(template, "workflow.update", body);
2313
+ validateWorkflowRender(body, "workflow.update");
2314
+ await assertRoleParticipantScopes(session, body);
2315
+ // Live: workflow/update does NOT run the template validator — it stores a
2316
+ // refused participant silently and only /publish answers the
2317
+ // ValidateMessage[]. So the guard belongs on publish/bindRule, not here.
2318
+ const performed = await wireCall(session, { path: ENDPOINTS.workflowUpdate, method: "POST", body });
2319
+ const draft = await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.code } });
2320
+ const data = (draft.data ?? null);
2321
+ if (!data)
2322
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.update returned no draft" });
2323
+ validateWorkflowRender(data, "workflow.update readback");
2324
+ const participantWarnings = participantReferenceWarnings(data);
2325
+ // Evaluate on `body` (post-backfill): the readback may drop the handler
2326
+ // keys, and the backfilled permittedAction is exactly what will run.
2327
+ const rejectWarnings = rejectToStartCancelWarnings(body);
2328
+ const renamedActivities = renamedActivityPairs(template, data);
2329
+ const renamedInPlace = inPlaceRenames(data);
2330
+ // Caller sent no routes at all: the draft's edges (branches included) are
2331
+ // what rides the wire. Say so, and name the branch edges, because the only
2332
+ // other trace is a missing edge in a later getPublished readback.
2333
+ const callerSentRoutes = template.routes !== undefined || template.edges !== undefined || template.rules !== undefined;
2334
+ const branchEdges = callerSentRoutes ? [] : inheritedBranchEdges(data);
2335
+ // 改名验收的权威读数:每个节点的当前显示名(platform may keep
2336
+ // activityName or sync it; effectiveActivityName resolves both shapes).
2337
+ const rawActivityRows = Array.isArray(data.activities)
2338
+ ? data.activities
2339
+ : data.activities && typeof data.activities === "object"
2340
+ ? Object.values(data.activities)
2341
+ : [];
2342
+ const activityNames = Object.fromEntries(rawActivityRows
2343
+ .map((row) => [String(row.activityCode ?? ""), effectiveActivityName(row)])
2344
+ .filter(([code]) => code !== ""));
2345
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, rollbackHint: `本次改动的 before 快照已记录:omc rollback plan --target workflow:${args.schemaCode}/${args.code} 查看可回退项,omc rollback apply --target … --step <n> 执行(草稿回退后仍需 workflow.publish 才生效)`, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2346
+ },
2347
+ preflight: (args) => {
2348
+ // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
2349
+ // governed pipeline — including the rollback before-capture read.
2350
+ assertTemplateNotMinimal(args.template ?? {}, "workflow.update");
2351
+ },
2352
+ dryRunPayload: // Dry-run must actually look at the merge result: shape-only previews said
2353
+ // "wouldChange" while the write silently dropped 15 of 46 edges (issue
2354
+ // 2026-10-22 §1.2). The payload runs the SAME merge as the write (reads
2355
+ // only) and reports the edge delta; the contraction refusal fires here too.
2356
+ async (session, args) => {
2357
+ const template = args.template ?? {};
2358
+ const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
2359
+ const draft = await getWorkflowDraftOrNull(session, args.code);
2360
+ if (draft === null)
2361
+ throw new ToolRefusalError({
2362
+ status: "refused",
2363
+ blocker: "workflow-not-found",
2364
+ workflowCode: args.code,
2365
+ message: `流程 ${args.code} 不存在(get_draft 401001 缺席)——先 workflow.create 再 update/publish`,
2366
+ });
2367
+ const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
2368
+ validateWorkflowRender(body, "workflow.update");
2369
+ const edgeKeys = (rules) => (Array.isArray(rules) ? rules : []).map((rule) => `${String(rule.preActivityCode ?? "")}->${String(rule.postActivityCode ?? "")}`);
2370
+ const beforeEdges = edgeKeys(draft.rules);
2371
+ const afterEdges = edgeKeys(body.rules);
2372
+ // Multiset difference: two conditional lines can share one node pair, so a
2373
+ // Set would hide a real drop.
2374
+ const remaining = [...afterEdges];
2375
+ const routesDropped = [];
2376
+ for (const edge of beforeEdges) {
2377
+ const index = remaining.indexOf(edge);
2378
+ if (index >= 0)
2379
+ remaining.splice(index, 1);
2380
+ else
2381
+ routesDropped.push(edge);
2382
+ }
2383
+ const beforeLeft = [...beforeEdges];
2384
+ const routesAdded = [];
2385
+ for (const edge of afterEdges) {
2386
+ const index = beforeLeft.indexOf(edge);
2387
+ if (index >= 0)
2388
+ beforeLeft.splice(index, 1);
2389
+ else
2390
+ routesAdded.push(edge);
2391
+ }
2392
+ const activityCodes = (rows) => {
2393
+ const list = Array.isArray(rows) ? rows : rows && typeof rows === "object" ? Object.values(rows) : [];
2394
+ return list.map((row) => String(row?.activityCode ?? "")).filter((code) => code !== "");
2395
+ };
2396
+ const callerSentRoutes = template.routes !== undefined || template.edges !== undefined || template.rules !== undefined;
2397
+ return {
2398
+ merge,
2399
+ callerSentRoutes,
2400
+ activitiesBefore: activityCodes(draft.activities).length,
2401
+ activitiesAfter: activityCodes(body.activities).length,
2402
+ routesBefore: beforeEdges.length,
2403
+ routesAfter: afterEdges.length,
2404
+ routesAdded,
2405
+ routesDropped,
2406
+ hint: callerSentRoutes
2407
+ ? "本次显式给了连线:merge 是并集(草稿边 + 你的边),不会删边"
2408
+ : "本次未给 routes/edges/rules:草稿连线原样沿用(新增节点才会自动接链)",
2409
+ };
2410
+ },
2411
+ rollback: workflowRollbackSpec("workflow.update"),
2412
+ });
2413
+ writeTool({
2414
+ tools,
2415
+ name: "workflow.publish",
2416
+ lane: WORKFLOW_LANE,
2417
+ scope: WORKFLOW_TOOLS["workflow.publish"].scope,
2418
+ description: WORKFLOW_TOOLS["workflow.publish"].description,
2419
+ inputSchema: { schemaCode: z.string().min(1), code: z.string().min(1).describe("流程编码——本参数名是 code,传 workflowCode 会被 schema 拒绝"), template: workflowTemplateArg.optional() },
2420
+ execute: async (session, args) => {
2421
+ // An absent template publishes the stored draft as-is — the platform's own
2422
+ // path. The schema is optional so the contract no longer contradicts it.
2423
+ const template = args.template ?? {};
2424
+ validateCallerActivities(template, "workflow.publish");
2425
+ validateWorkflowRouteConditions(template, "workflow.publish");
2426
+ assertTemplateNotMinimal(template, "workflow.publish");
2427
+ await refuseRequiredOnChildTableFields(session, args.schemaCode, template, "workflow.publish");
2428
+ lintWorkflowTemplate(template, "workflow.publish");
2429
+ // Ticket 112: same merge-before-write as update. A template without both
2430
+ // boundaries is a partial edit → delta merge (see activitiesLackBoundaries).
2431
+ const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
2432
+ await backfillDesignerDefaults(session, args.schemaCode, body, template);
2433
+ assertFirstActivityIsOriginator(template, "workflow.publish", body);
2434
+ const unregistered = unregisteredParticipantFunctions(body);
2435
+ if (unregistered.length > 0)
2436
+ throw new ToolRefusalError({
2437
+ status: "refused",
2438
+ blocker: "workflow-participant-function-unregistered",
2439
+ workflowCode: args.code,
2440
+ activities: unregistered,
2441
+ message: `流程发布被拒:参与人函数 ${[...new Set(unregistered.map((entry) => entry.functionName + "()"))].join("、")} 在 AI-Platform v1.1.25 未注册(节点 ${unregistered.map((entry) => entry.activityCode).join("、")})。` +
2442
+ `发布能过,但运行期 ParticipantActivityRuntime 抛 FunctionNotFoundException:实例卡 PROCESSING、不生成待办,而 data.submit 仍回 errCode 0(r4-training 真机)。` +
2443
+ `内置函数只有 FindUserByRole({Originator.OU},"<roleCode>","random");跨部门/全局角色改用参与者对象数组(角色 id)。` +
2444
+ `这两个名字常见于 workflow.get_published 读回与种子流程,不要回抄。`,
2445
+ });
2446
+ validateWorkflowRender(body, "workflow.publish");
2447
+ const performed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
2448
+ // A refused publish answers errcode 0 — the ValidateMessage[] is the truth.
2449
+ assertWorkflowWriteAccepted(performed, "workflow.publish", args.code);
2450
+ const published = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.code } });
2451
+ const data = (published.data ?? null);
2452
+ // Ticket 77: publish evidence is the published-face MARKER, not data
2453
+ // presence (the endpoint answers the draft shape when unpublished).
2454
+ const isPublished = data !== null && (data.templateType === "PUBLISH" || data.published === true);
2455
+ if (!data)
2456
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.publish returned no published template" });
2457
+ const warnings = workflowValidationWarnings(performed.data);
2458
+ // The platform accepted the write (no ERROR) yet the published face still
2459
+ // shows the draft marker: surface that honestly instead of implying the
2460
+ // flow went live.
2461
+ if (!isPublished)
2462
+ throw new ToolRefusalError({
2463
+ status: "refused",
2464
+ blocker: "workflow-publish-not-applied",
2465
+ workflowCode: args.code,
2466
+ message: `workflow.publish: 平台接受写入(errcode 0)但读回仍是草稿形态(templateType=${String(data.templateType ?? "null")}, published=${String(data.published ?? "null")})——流程未生效。先 workflow.validate 复核模板,修正后重试`,
2467
+ });
2468
+ validateWorkflowRender(data, "workflow.publish readback");
2469
+ const participantWarnings = participantReferenceWarnings(data);
2470
+ const rejectWarnings = rejectToStartCancelWarnings(data);
2471
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2472
+ },
2473
+ preflight: (args) => {
2474
+ assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");
2475
+ },
2476
+ rollback: workflowRollbackSpec("workflow.publish"),
2477
+ });
2478
+ writeTool({
2479
+ tools,
2480
+ name: "workflow.bindRule",
2481
+ lane: WORKFLOW_LANE,
2482
+ scope: WORKFLOW_TOOLS["workflow.bindRule"].scope,
2483
+ description: WORKFLOW_TOOLS["workflow.bindRule"].description,
2484
+ inputSchema: {
2485
+ schemaCode: z.string().min(1),
2486
+ workflowCode: z.string().min(1),
2487
+ ruleCode: z.string().min(1).optional().describe("single-rule form: the rule to bind (mutually exclusive with ruleCodes)"),
2488
+ 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)"),
2489
+ handler: z
2490
+ .string()
2491
+ .optional()
2492
+ .describe("FLOW-level event handler to bind: start | cancel | activate | end (default end). 与 activityCode+nodeEvent 互斥"),
2493
+ activityCode: z
2494
+ .string()
2495
+ .min(1)
2496
+ .optional()
2497
+ .describe("NODE-level target activity (草稿里的 activityCode,如 Activity2). 必须与 nodeEvent 同时给;节点不存在时报 workflow-activity-not-found 并列出可用 code"),
2498
+ nodeEvent: z
2499
+ .enum(["beforeActivate", "afterActivate", "endActivity", "cancelActivity", "activityApproved", "activityRejected", "jobSubmitted", "jobRejected"])
2500
+ .optional()
2501
+ .describe("NODE-level event on activityCode: beforeActivate|afterActivate|endActivity|cancelActivity|activityApproved|activityRejected|jobSubmitted|jobRejected(如填写节点 endActivity = 申请人提交离开该节点时). 与 handler 互斥"),
2502
+ publish: z.boolean().optional(),
2503
+ },
2504
+ execute: async (session, args) => {
2505
+ const target = resolveBindTarget(args.handler, args.nodeEvent, args.activityCode);
2506
+ const codes = (args.ruleCodes ?? [args.ruleCode]).map(String);
2507
+ // Only CUSTOM rules are bindable to workflow events — the designer's picker
2508
+ // does not offer preset trigger rows (bench59 bound `Create` to `end`).
2509
+ const bindableByCode = await assertBindableWorkflowRules(session, args.schemaCode, codes);
2510
+ // A bound workflow rule MUST be enabled (else 失效/未启用). Enable any
2511
+ // disabled bound custom rule before writing the binding.
2512
+ const { enabledRuleCodes, alreadyEnabledRuleCodes } = await ensureWorkflowRulesEnabled(session, args.schemaCode, codes, bindableByCode);
2513
+ const shouldPublish = args.publish !== false;
2514
+ const draft = (await getWorkflowDraftOrNull(session, args.workflowCode)) ?? {};
2515
+ if (Object.keys(draft).length === 0) {
2516
+ throw new ToolRefusalError({
2517
+ status: "refused",
2518
+ blocker: "workflow-not-found",
2519
+ workflowCode: args.workflowCode,
2520
+ message: `流程 ${args.workflowCode} 不存在(get_draft 401001 缺席)——先 workflow.create,再 bindRule`,
2521
+ });
2522
+ }
2523
+ // Node-level: fail closed BEFORE the wire when the activity code is not in
2524
+ // the draft, and name the codes that ARE (the designer's own codes like
2525
+ // Activity2 are the common miss).
2526
+ if (target.scope === "activity" && !findTemplateActivity(draft, target.activityCode)) {
2527
+ throw new ToolRefusalError({
2528
+ status: "refused",
2529
+ blocker: "workflow-activity-not-found",
2530
+ workflowCode: args.workflowCode,
2531
+ activityCode: target.activityCode,
2532
+ activities: templateActivityCodes(draft),
2533
+ message: `流程 ${args.workflowCode} 的草稿里没有活动 ${target.activityCode}——节点级事件必须指向已存在的活动。可用活动:${templateActivityCodes(draft).join("、") || "(无)"}(先用 workflow.get_draft 看 code,别用节点名)`,
2534
+ });
2535
+ }
2536
+ const handlerOf = (template) => {
2537
+ if (target.scope === "workflow")
2538
+ return (template?.[target.field] ?? {}) ?? {};
2539
+ return (findTemplateActivity(template, target.activityCode)?.[target.field] ?? {}) ?? {};
2540
+ };
2541
+ const existing = handlerOf(draft);
2542
+ const priorActions = Array.isArray(existing.bizActions)
2543
+ ? existing.bizActions.filter((value) => typeof value === "string")
2325
2544
  : [];
2326
- const activityNames = Object.fromEntries(rawActivityRows
2327
- .map((row) => [String(row.activityCode ?? ""), effectiveActivityName(row)])
2328
- .filter(([code]) => code !== ""));
2329
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, rollbackHint: `本次改动的 before 快照已记录:omc rollback plan --target workflow:${args.schemaCode}/${args.code} 查看可回退项,omc rollback apply --target … --step <n> 执行(草稿回退后仍需 workflow.publish 才生效)`, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2330
- }, (args) => {
2331
- // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
2332
- // governed pipeline — including the rollback before-capture read.
2333
- assertTemplateNotMinimal(args.template ?? {}, "workflow.update");
2334
- },
2335
- // Dry-run must actually look at the merge result: shape-only previews said
2336
- // "wouldChange" while the write silently dropped 15 of 46 edges (issue
2337
- // 2026-10-22 §1.2). The payload runs the SAME merge as the write (reads
2338
- // only) and reports the edge delta; the contraction refusal fires here too.
2339
- async (session, args) => {
2340
- const template = args.template ?? {};
2341
- const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
2342
- const draft = await getWorkflowDraftOrNull(session, args.code);
2343
- if (draft === null)
2344
- throw new ToolRefusalError({
2345
- status: "refused",
2346
- blocker: "workflow-not-found",
2347
- workflowCode: args.code,
2348
- message: `流程 ${args.code} 不存在(get_draft 401001 缺席)——先 workflow.create 再 update/publish`,
2349
- });
2350
- const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
2351
- validateWorkflowRender(body, "workflow.update");
2352
- const edgeKeys = (rules) => (Array.isArray(rules) ? rules : []).map((rule) => `${String(rule.preActivityCode ?? "")}->${String(rule.postActivityCode ?? "")}`);
2353
- const beforeEdges = edgeKeys(draft.rules);
2354
- const afterEdges = edgeKeys(body.rules);
2355
- // Multiset difference: two conditional lines can share one node pair, so a
2356
- // Set would hide a real drop.
2357
- const remaining = [...afterEdges];
2358
- const routesDropped = [];
2359
- for (const edge of beforeEdges) {
2360
- const index = remaining.indexOf(edge);
2361
- if (index >= 0)
2362
- remaining.splice(index, 1);
2363
- else
2364
- routesDropped.push(edge);
2365
- }
2366
- const beforeLeft = [...beforeEdges];
2367
- const routesAdded = [];
2368
- for (const edge of afterEdges) {
2369
- const index = beforeLeft.indexOf(edge);
2370
- if (index >= 0)
2371
- beforeLeft.splice(index, 1);
2372
- else
2373
- routesAdded.push(edge);
2374
- }
2375
- const activityCodes = (rows) => {
2376
- const list = Array.isArray(rows) ? rows : rows && typeof rows === "object" ? Object.values(rows) : [];
2377
- return list.map((row) => String(row?.activityCode ?? "")).filter((code) => code !== "");
2378
- };
2379
- const callerSentRoutes = template.routes !== undefined || template.edges !== undefined || template.rules !== undefined;
2380
- return {
2381
- merge,
2382
- callerSentRoutes,
2383
- activitiesBefore: activityCodes(draft.activities).length,
2384
- activitiesAfter: activityCodes(body.activities).length,
2385
- routesBefore: beforeEdges.length,
2386
- routesAfter: afterEdges.length,
2387
- routesAdded,
2388
- routesDropped,
2389
- hint: callerSentRoutes
2390
- ? "本次显式给了连线:merge 是并集(草稿边 + 你的边),不会删边"
2391
- : "本次未给 routes/edges/rules:草稿连线原样沿用(新增节点才会自动接链)",
2392
- };
2393
- }, workflowRollbackSpec("workflow.update"));
2394
- 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).describe("流程编码——本参数名是 code,传 workflowCode 会被 schema 拒绝"), template: workflowTemplateArg.optional() }, async (session, args) => {
2395
- // An absent template publishes the stored draft as-is — the platform's own
2396
- // path. The schema is optional so the contract no longer contradicts it.
2397
- const template = args.template ?? {};
2398
- validateCallerActivities(template, "workflow.publish");
2399
- validateWorkflowRouteConditions(template, "workflow.publish");
2400
- assertTemplateNotMinimal(template, "workflow.publish");
2401
- await refuseRequiredOnChildTableFields(session, args.schemaCode, template, "workflow.publish");
2402
- lintWorkflowTemplate(template, "workflow.publish");
2403
- // Ticket 112: same merge-before-write as update. A template without both
2404
- // boundaries is a partial edit → delta merge (see activitiesLackBoundaries).
2405
- const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
2406
- await backfillDesignerDefaults(session, args.schemaCode, body, template);
2407
- assertFirstActivityIsOriginator(template, "workflow.publish", body);
2408
- const unregistered = unregisteredParticipantFunctions(body);
2409
- if (unregistered.length > 0)
2410
- throw new ToolRefusalError({
2411
- status: "refused",
2412
- blocker: "workflow-participant-function-unregistered",
2413
- workflowCode: args.code,
2414
- activities: unregistered,
2415
- message: `流程发布被拒:参与人函数 ${[...new Set(unregistered.map((entry) => entry.functionName + "()"))].join("、")} 在 AI-Platform v1.1.25 未注册(节点 ${unregistered.map((entry) => entry.activityCode).join("、")})。` +
2416
- `发布能过,但运行期 ParticipantActivityRuntime 抛 FunctionNotFoundException:实例卡 PROCESSING、不生成待办,而 data.submit 仍回 errCode 0(r4-training 真机)。` +
2417
- `内置函数只有 FindUserByRole({Originator.OU},"<roleCode>","random");跨部门/全局角色改用参与者对象数组(角色 id)。` +
2418
- `这两个名字常见于 workflow.get_published 读回与种子流程,不要回抄。`,
2419
- });
2420
- validateWorkflowRender(body, "workflow.publish");
2421
- const performed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
2422
- // A refused publish answers errcode 0 — the ValidateMessage[] is the truth.
2423
- assertWorkflowWriteAccepted(performed, "workflow.publish", args.code);
2424
- const published = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.code } });
2425
- const data = (published.data ?? null);
2426
- // Ticket 77: publish evidence is the published-face MARKER, not data
2427
- // presence (the endpoint answers the draft shape when unpublished).
2428
- const isPublished = data !== null && (data.templateType === "PUBLISH" || data.published === true);
2429
- if (!data)
2430
- throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.publish returned no published template" });
2431
- const warnings = workflowValidationWarnings(performed.data);
2432
- // The platform accepted the write (no ERROR) yet the published face still
2433
- // shows the draft marker: surface that honestly instead of implying the
2434
- // flow went live.
2435
- if (!isPublished)
2436
- throw new ToolRefusalError({
2437
- status: "refused",
2438
- blocker: "workflow-publish-not-applied",
2439
- workflowCode: args.code,
2440
- message: `workflow.publish: 平台接受写入(errcode 0)但读回仍是草稿形态(templateType=${String(data.templateType ?? "null")}, published=${String(data.published ?? "null")})——流程未生效。先 workflow.validate 复核模板,修正后重试`,
2441
- });
2442
- validateWorkflowRender(data, "workflow.publish readback");
2443
- const participantWarnings = participantReferenceWarnings(data);
2444
- const rejectWarnings = rejectToStartCancelWarnings(data);
2445
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2446
- }, (args) => {
2447
- assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");
2448
- }, undefined, workflowRollbackSpec("workflow.publish"));
2449
- writeTool(tools, "workflow.bindRule", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.bindRule"].scope, WORKFLOW_TOOLS["workflow.bindRule"].description, {
2450
- schemaCode: z.string().min(1),
2451
- workflowCode: z.string().min(1),
2452
- ruleCode: z.string().min(1).optional().describe("single-rule form: the rule to bind (mutually exclusive with ruleCodes)"),
2453
- 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)"),
2454
- handler: z
2455
- .string()
2456
- .optional()
2457
- .describe("FLOW-level event handler to bind: start | cancel | activate | end (default end). 与 activityCode+nodeEvent 互斥"),
2458
- activityCode: z
2459
- .string()
2460
- .min(1)
2461
- .optional()
2462
- .describe("NODE-level target activity (草稿里的 activityCode,如 Activity2). 必须与 nodeEvent 同时给;节点不存在时报 workflow-activity-not-found 并列出可用 code"),
2463
- nodeEvent: z
2464
- .enum(["beforeActivate", "afterActivate", "endActivity", "cancelActivity", "activityApproved", "activityRejected", "jobSubmitted", "jobRejected"])
2465
- .optional()
2466
- .describe("NODE-level event on activityCode: beforeActivate|afterActivate|endActivity|cancelActivity|activityApproved|activityRejected|jobSubmitted|jobRejected(如填写节点 endActivity = 申请人提交离开该节点时). 与 handler 互斥"),
2467
- publish: z.boolean().optional(),
2468
- }, async (session, args) => {
2469
- const target = resolveBindTarget(args.handler, args.nodeEvent, args.activityCode);
2470
- const codes = (args.ruleCodes ?? [args.ruleCode]).map(String);
2471
- // Only CUSTOM rules are bindable to workflow events — the designer's picker
2472
- // does not offer preset trigger rows (bench59 bound `Create` to `end`).
2473
- const bindableByCode = await assertBindableWorkflowRules(session, args.schemaCode, codes);
2474
- // A bound workflow rule MUST be enabled (else 失效/未启用). Enable any
2475
- // disabled bound custom rule before writing the binding.
2476
- const { enabledRuleCodes, alreadyEnabledRuleCodes } = await ensureWorkflowRulesEnabled(session, args.schemaCode, codes, bindableByCode);
2477
- const shouldPublish = args.publish !== false;
2478
- const draft = (await getWorkflowDraftOrNull(session, args.workflowCode)) ?? {};
2479
- if (Object.keys(draft).length === 0) {
2480
- throw new ToolRefusalError({
2481
- status: "refused",
2482
- blocker: "workflow-not-found",
2483
- workflowCode: args.workflowCode,
2484
- message: `流程 ${args.workflowCode} 不存在(get_draft 401001 缺席)——先 workflow.create,再 bindRule`,
2485
- });
2486
- }
2487
- // Node-level: fail closed BEFORE the wire when the activity code is not in
2488
- // the draft, and name the codes that ARE (the designer's own codes like
2489
- // Activity2 are the common miss).
2490
- if (target.scope === "activity" && !findTemplateActivity(draft, target.activityCode)) {
2491
- throw new ToolRefusalError({
2492
- status: "refused",
2493
- blocker: "workflow-activity-not-found",
2494
- workflowCode: args.workflowCode,
2495
- activityCode: target.activityCode,
2496
- activities: templateActivityCodes(draft),
2497
- message: `流程 ${args.workflowCode} 的草稿里没有活动 ${target.activityCode}——节点级事件必须指向已存在的活动。可用活动:${templateActivityCodes(draft).join("、") || "(无)"}(先用 workflow.get_draft 看 code,别用节点名)`,
2498
- });
2499
- }
2500
- const handlerOf = (template) => {
2501
- if (target.scope === "workflow")
2502
- return (template?.[target.field] ?? {}) ?? {};
2503
- return (findTemplateActivity(template, target.activityCode)?.[target.field] ?? {}) ?? {};
2504
- };
2505
- const existing = handlerOf(draft);
2506
- const priorActions = Array.isArray(existing.bizActions)
2507
- ? existing.bizActions.filter((value) => typeof value === "string")
2508
- : [];
2509
- // MERGE: existing bizActions are preserved; the listed rules are
2510
- // appended in order, deduplicated (same semantics the single-rule form
2511
- // has always had — replace is NOT the contract).
2512
- const nextActions = [...priorActions];
2513
- for (const code of codes)
2514
- if (!nextActions.includes(code))
2515
- nextActions.push(code);
2516
- const mergedHandler = {
2517
- ...(typeof existing === "object" && existing ? existing : {}),
2518
- bizActions: nextActions,
2519
- bizActionType: "BIZ_RULE",
2520
- };
2521
- let nextTemplate;
2522
- if (target.scope === "workflow") {
2523
- nextTemplate = { ...draft, [target.field]: mergedHandler, workflowCode: args.workflowCode, schemaCode: args.schemaCode };
2524
- }
2525
- else {
2526
- // Keep the draft's activities container shape (array or index-keyed
2527
- // object) — only the selected row's handler field changes.
2528
- nextTemplate = mapTemplateActivity(draft, target.activityCode, (row) => ({ ...row, [target.field]: mergedHandler })).template;
2529
- nextTemplate = { ...nextTemplate, workflowCode: args.workflowCode, schemaCode: args.schemaCode };
2530
- }
2531
- validateWorkflowRender(nextTemplate, "workflow.bindRule");
2532
- const performed = await wireCall(session, {
2533
- path: ENDPOINTS.workflowUpdate,
2534
- method: "POST",
2535
- body: nextTemplate,
2536
- });
2537
- let publishedReadback = null;
2538
- let publishMarked = false;
2539
- if (shouldPublish) {
2540
- const publishPerformed = await wireCall(session, {
2541
- path: ENDPOINTS.workflowPublish,
2545
+ // MERGE: existing bizActions are preserved; the listed rules are
2546
+ // appended in order, deduplicated (same semantics the single-rule form
2547
+ // has always had — replace is NOT the contract).
2548
+ const nextActions = [...priorActions];
2549
+ for (const code of codes)
2550
+ if (!nextActions.includes(code))
2551
+ nextActions.push(code);
2552
+ const mergedHandler = {
2553
+ ...(typeof existing === "object" && existing ? existing : {}),
2554
+ bizActions: nextActions,
2555
+ bizActionType: "BIZ_RULE",
2556
+ };
2557
+ let nextTemplate;
2558
+ if (target.scope === "workflow") {
2559
+ nextTemplate = { ...draft, [target.field]: mergedHandler, workflowCode: args.workflowCode, schemaCode: args.schemaCode };
2560
+ }
2561
+ else {
2562
+ // Keep the draft's activities container shape (array or index-keyed
2563
+ // object) — only the selected row's handler field changes.
2564
+ nextTemplate = mapTemplateActivity(draft, target.activityCode, (row) => ({ ...row, [target.field]: mergedHandler })).template;
2565
+ nextTemplate = { ...nextTemplate, workflowCode: args.workflowCode, schemaCode: args.schemaCode };
2566
+ }
2567
+ validateWorkflowRender(nextTemplate, "workflow.bindRule");
2568
+ const performed = await wireCall(session, {
2569
+ path: ENDPOINTS.workflowUpdate,
2542
2570
  method: "POST",
2543
2571
  body: nextTemplate,
2544
2572
  });
2545
- // A refused publish answers errcode 0 with the truth in ValidateMessage[].
2546
- assertWorkflowWriteAccepted(publishPerformed, "workflow.bindRule", args.workflowCode);
2547
- const pub = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.workflowCode } });
2548
- publishedReadback = (pub.data ?? null);
2549
- // Authoritative marker, not the request echo (ticket 77 semantics).
2550
- publishMarked = publishedReadback !== null && (publishedReadback.templateType === "PUBLISH" || publishedReadback.published === true);
2551
- }
2552
- const draftReadback = await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.workflowCode } });
2553
- const data = (draftReadback.data ?? null);
2554
- if (!data)
2555
- throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.bindRule returned no draft" });
2556
- validateWorkflowRender(data, "workflow.bindRule readback");
2557
- const handler = handlerOf(data);
2558
- const bizActions = Array.isArray(handler?.bizActions) ? handler.bizActions.filter((value) => typeof value === "string") : [];
2559
- const publishedHandler = publishedReadback ? handlerOf(publishedReadback) : undefined;
2560
- const publishedBizActions = Array.isArray(publishedHandler?.bizActions)
2561
- ? publishedHandler.bizActions.filter((value) => typeof value === "string")
2562
- : [];
2563
- const targetShape = target.scope === "workflow"
2564
- ? { handler: target.key, handlerField: target.field }
2565
- : { activityCode: target.activityCode, nodeEvent: target.key, nodeEventField: target.field };
2566
- return {
2567
- performed: { errcode: performed.errcode, scope: target.scope, ...targetShape, addedRuleCodes: codes, bizActions, publishRequested: shouldPublish },
2568
- readback: {
2569
- workflowCode: args.workflowCode,
2570
- scope: target.scope,
2571
- ...targetShape,
2572
- semantics: "merge",
2573
- bound: codes.every((code) => bizActions.includes(code)),
2574
- bizActions,
2575
- // Workflow-bound rules must be enabled; report the enable delta.
2576
- enabledRuleCodes,
2577
- alreadyEnabledRuleCodes,
2578
- bizActionType: typeof handler?.bizActionType === "string" ? handler.bizActionType : null,
2579
- templateFieldsPreserved: target.scope === "workflow"
2580
- ? Object.keys(draft).every((key) => key === target.field || data[key] !== undefined)
2581
- : Object.keys(draft).every((key) => data[key] !== undefined),
2582
- // Authoritative published-face marker, NOT the request echo: a
2583
- // refused publish keeps errcode 0 and the draft marker (live 8.6.26).
2584
- published: publishMarked,
2585
- publishedBound: codes.every((code) => publishedBizActions.includes(code)),
2586
- publishedBizActions,
2587
- publishedBizActionType: typeof publishedHandler?.bizActionType === "string" ? publishedHandler.bizActionType : null,
2588
- ...(participantReferenceWarnings(data).length ? { participantWarnings: participantReferenceWarnings(data) } : {}),
2589
- ...(bizActions.length > 1
2590
- ? { advisory: `同一事件处理器绑定了 ${bizActions.length} 条规则,它们会**全部执行**(没有互斥):审批通过/驳回这类互斥结果不要用两条各自 FIXED 赋值的规则——两次都会写、后者覆盖前者(bench22 病灶:通过→已通过、驳回→已驳回 两条各自 FIXED 赋值都绑 endEventHandler)。正确做法二选一:① **绑定到不同处理器/触发**——通过绑流程结束处理器 \`end\`(endEventHandler)或流程生效规则 \`Available\`,驳回绑流程作废处理器 \`cancel\`(cancelEventHandler)或规则 \`Cancel\`;② **一条规则内部按条件分支**(规则图用 BRANCH/filterCondition/dataCondition 读审批结果,多分支各写各的)。真实生产(2026-09-23,case_info_update)只在 endEventHandler 绑一条多分支图(updateData),由该图内部分支完成回写。` }
2591
- : {}),
2592
- workflowRenderCompat: workflowRenderCompat(data),
2593
- },
2594
- };
2595
- }, (args) => {
2596
- resolveBindTarget(args.handler, args.nodeEvent, args.activityCode);
2597
- const hasSingle = typeof args.ruleCode === "string" && args.ruleCode.length > 0;
2598
- const hasMany = Array.isArray(args.ruleCodes);
2599
- if (hasSingle && hasMany)
2600
- throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-exclusive", message: "ruleCode 与 ruleCodes 互斥:单规则用 ruleCode,多规则用 ruleCodes[]" });
2601
- if (!hasSingle && !hasMany)
2602
- throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "需要 ruleCode(单个)或 ruleCodes(数组)之一" });
2603
- if (hasMany) {
2604
- const codes = args.ruleCodes;
2605
- if (codes.length === 0 || new Set(codes.map(String)).size !== codes.length || codes.some((code) => typeof code !== "string" || code.length === 0))
2606
- throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "ruleCodes 必须是非空、去重的规则 code 数组" });
2607
- }
2608
- }, undefined, workflowRollbackSpec("workflow.bindRule"));
2573
+ let publishedReadback = null;
2574
+ let publishMarked = false;
2575
+ if (shouldPublish) {
2576
+ const publishPerformed = await wireCall(session, {
2577
+ path: ENDPOINTS.workflowPublish,
2578
+ method: "POST",
2579
+ body: nextTemplate,
2580
+ });
2581
+ // A refused publish answers errcode 0 with the truth in ValidateMessage[].
2582
+ assertWorkflowWriteAccepted(publishPerformed, "workflow.bindRule", args.workflowCode);
2583
+ const pub = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.workflowCode } });
2584
+ publishedReadback = (pub.data ?? null);
2585
+ // Authoritative marker, not the request echo (ticket 77 semantics).
2586
+ publishMarked = publishedReadback !== null && (publishedReadback.templateType === "PUBLISH" || publishedReadback.published === true);
2587
+ }
2588
+ const draftReadback = await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.workflowCode } });
2589
+ const data = (draftReadback.data ?? null);
2590
+ if (!data)
2591
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.bindRule returned no draft" });
2592
+ validateWorkflowRender(data, "workflow.bindRule readback");
2593
+ const handler = handlerOf(data);
2594
+ const bizActions = Array.isArray(handler?.bizActions) ? handler.bizActions.filter((value) => typeof value === "string") : [];
2595
+ const publishedHandler = publishedReadback ? handlerOf(publishedReadback) : undefined;
2596
+ const publishedBizActions = Array.isArray(publishedHandler?.bizActions)
2597
+ ? publishedHandler.bizActions.filter((value) => typeof value === "string")
2598
+ : [];
2599
+ const targetShape = target.scope === "workflow"
2600
+ ? { handler: target.key, handlerField: target.field }
2601
+ : { activityCode: target.activityCode, nodeEvent: target.key, nodeEventField: target.field };
2602
+ return {
2603
+ performed: { errcode: performed.errcode, scope: target.scope, ...targetShape, addedRuleCodes: codes, bizActions, publishRequested: shouldPublish },
2604
+ readback: {
2605
+ workflowCode: args.workflowCode,
2606
+ scope: target.scope,
2607
+ ...targetShape,
2608
+ semantics: "merge",
2609
+ bound: codes.every((code) => bizActions.includes(code)),
2610
+ bizActions,
2611
+ // Workflow-bound rules must be enabled; report the enable delta.
2612
+ enabledRuleCodes,
2613
+ alreadyEnabledRuleCodes,
2614
+ bizActionType: typeof handler?.bizActionType === "string" ? handler.bizActionType : null,
2615
+ templateFieldsPreserved: target.scope === "workflow"
2616
+ ? Object.keys(draft).every((key) => key === target.field || data[key] !== undefined)
2617
+ : Object.keys(draft).every((key) => data[key] !== undefined),
2618
+ // Authoritative published-face marker, NOT the request echo: a
2619
+ // refused publish keeps errcode 0 and the draft marker (live 8.6.26).
2620
+ published: publishMarked,
2621
+ publishedBound: codes.every((code) => publishedBizActions.includes(code)),
2622
+ publishedBizActions,
2623
+ publishedBizActionType: typeof publishedHandler?.bizActionType === "string" ? publishedHandler.bizActionType : null,
2624
+ ...(participantReferenceWarnings(data).length ? { participantWarnings: participantReferenceWarnings(data) } : {}),
2625
+ ...(bizActions.length > 1
2626
+ ? { advisory: `同一事件处理器绑定了 ${bizActions.length} 条规则,它们会**全部执行**(没有互斥):审批通过/驳回这类互斥结果不要用两条各自 FIXED 赋值的规则——两次都会写、后者覆盖前者(bench22 病灶:通过→已通过、驳回→已驳回 两条各自 FIXED 赋值都绑 endEventHandler)。正确做法二选一:① **绑定到不同处理器/触发**——通过绑流程结束处理器 \`end\`(endEventHandler)或流程生效规则 \`Available\`,驳回绑流程作废处理器 \`cancel\`(cancelEventHandler)或规则 \`Cancel\`;② **一条规则内部按条件分支**(规则图用 BRANCH/filterCondition/dataCondition 读审批结果,多分支各写各的)。真实生产(2026-09-23,case_info_update)只在 endEventHandler 绑一条多分支图(updateData),由该图内部分支完成回写。` }
2627
+ : {}),
2628
+ workflowRenderCompat: workflowRenderCompat(data),
2629
+ },
2630
+ };
2631
+ },
2632
+ preflight: (args) => {
2633
+ resolveBindTarget(args.handler, args.nodeEvent, args.activityCode);
2634
+ const hasSingle = typeof args.ruleCode === "string" && args.ruleCode.length > 0;
2635
+ const hasMany = Array.isArray(args.ruleCodes);
2636
+ if (hasSingle && hasMany)
2637
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-exclusive", message: "ruleCode 与 ruleCodes 互斥:单规则用 ruleCode,多规则用 ruleCodes[]" });
2638
+ if (!hasSingle && !hasMany)
2639
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "需要 ruleCode(单个)或 ruleCodes(数组)之一" });
2640
+ if (hasMany) {
2641
+ const codes = args.ruleCodes;
2642
+ if (codes.length === 0 || new Set(codes.map(String)).size !== codes.length || codes.some((code) => typeof code !== "string" || code.length === 0))
2643
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "ruleCodes 必须是非空、去重的规则 code 数组" });
2644
+ }
2645
+ },
2646
+ rollback: workflowRollbackSpec("workflow.bindRule"),
2647
+ });
2609
2648
  /**
2610
2649
  * Friendly writer for the designer's 「表单操作」 checkboxes: each PARTICIPANT
2611
2650
  * activity carries `buttonPermission.customBtns[]` = the form-toolbar buttons
@@ -2616,148 +2655,166 @@ export function registerWorkflowLane(tools) {
2616
2655
  * childTableBtns, which is exactly how a caller drops the child-table
2617
2656
  * defaults — this tool merges per node.
2618
2657
  */
2619
- writeTool(tools, "workflow.setButtons", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.setButtons"].scope, WORKFLOW_TOOLS["workflow.setButtons"].description, {
2620
- schemaCode: z.string().min(1),
2621
- workflowCode: z.string().min(1),
2622
- nodes: z
2623
- .array(z.object({
2624
- activityCode: z.string().min(1).describe("活动 code(workflow.get_draft 的 activities[].code)"),
2625
- buttonCodes: z.array(z.string().min(1)).describe("该节点要**启用**的表单按钮 code(按 form.get 的 formButtonCodes);未列出的置为 enabled:false,不删除子表按钮配置"),
2626
- }))
2627
- .min(1),
2628
- publish: z.boolean().optional().describe("默认 true:写草稿后发布,表单页立即生效;false 只写草稿"),
2629
- }, async (session, args) => {
2630
- const draft = (await getWorkflowDraftOrNull(session, args.workflowCode)) ?? {};
2631
- if (Object.keys(draft).length === 0)
2632
- throw new ToolRefusalError({
2633
- status: "refused",
2634
- blocker: "workflow-not-found",
2635
- workflowCode: args.workflowCode,
2636
- message: `流程 ${args.workflowCode} 不存在(get_draft 401001 缺席)`,
2637
- });
2638
- const available = await readSchemaFormButtons(session, args.schemaCode);
2639
- const availableCodes = Object.keys(available);
2640
- const draftCodes = templateActivityCodes(draft);
2641
- const patches = [];
2642
- const names = {};
2643
- for (const node of args.nodes) {
2644
- const row = findTemplateActivity(draft, node.activityCode);
2645
- if (!row) {
2658
+ writeTool({
2659
+ tools,
2660
+ name: "workflow.setButtons",
2661
+ lane: WORKFLOW_LANE,
2662
+ scope: WORKFLOW_TOOLS["workflow.setButtons"].scope,
2663
+ description: WORKFLOW_TOOLS["workflow.setButtons"].description,
2664
+ inputSchema: {
2665
+ schemaCode: z.string().min(1),
2666
+ workflowCode: z.string().min(1),
2667
+ nodes: z
2668
+ .array(z.object({
2669
+ activityCode: z.string().min(1).describe("活动 code(workflow.get_draft 的 activities[].code)"),
2670
+ buttonCodes: z.array(z.string().min(1)).describe("该节点要**启用**的表单按钮 code(按 form.get 的 formButtonCodes);未列出的置为 enabled:false,不删除子表按钮配置"),
2671
+ }))
2672
+ .min(1),
2673
+ publish: z.boolean().optional().describe("默认 true:写草稿后发布,表单页立即生效;false 只写草稿"),
2674
+ },
2675
+ execute: async (session, args) => {
2676
+ const draft = (await getWorkflowDraftOrNull(session, args.workflowCode)) ?? {};
2677
+ if (Object.keys(draft).length === 0)
2646
2678
  throw new ToolRefusalError({
2647
2679
  status: "refused",
2648
- blocker: "workflow-activity-not-found",
2680
+ blocker: "workflow-not-found",
2649
2681
  workflowCode: args.workflowCode,
2650
- activityCode: node.activityCode,
2651
- activities: draftCodes,
2652
- message: `流程 ${args.workflowCode} 没有活动 ${node.activityCode};可用:${draftCodes.join("、") || "(无)"}`,
2682
+ message: `流程 ${args.workflowCode} 不存在(get_draft 401001 缺席)`,
2653
2683
  });
2654
- }
2655
- const unknown = node.buttonCodes.filter((code) => available[code] === undefined);
2656
- if (unknown.length > 0) {
2657
- throw new ToolRefusalError({
2658
- status: "refused",
2659
- blocker: "form-button-absent",
2660
- schemaCode: args.schemaCode,
2684
+ const available = await readSchemaFormButtons(session, args.schemaCode);
2685
+ const availableCodes = Object.keys(available);
2686
+ const draftCodes = templateActivityCodes(draft);
2687
+ const patches = [];
2688
+ const names = {};
2689
+ for (const node of args.nodes) {
2690
+ const row = findTemplateActivity(draft, node.activityCode);
2691
+ if (!row) {
2692
+ throw new ToolRefusalError({
2693
+ status: "refused",
2694
+ blocker: "workflow-activity-not-found",
2695
+ workflowCode: args.workflowCode,
2696
+ activityCode: node.activityCode,
2697
+ activities: draftCodes,
2698
+ message: `流程 ${args.workflowCode} 没有活动 ${node.activityCode};可用:${draftCodes.join("、") || "(无)"}`,
2699
+ });
2700
+ }
2701
+ const unknown = node.buttonCodes.filter((code) => available[code] === undefined);
2702
+ if (unknown.length > 0) {
2703
+ throw new ToolRefusalError({
2704
+ status: "refused",
2705
+ blocker: "form-button-absent",
2706
+ schemaCode: args.schemaCode,
2707
+ activityCode: node.activityCode,
2708
+ buttonCodes: unknown,
2709
+ available: availableCodes,
2710
+ message: `按钮 ${unknown.join("、")} 不在模型 ${args.schemaCode} 的表单按钮里;先 form.publish {buttons:[…]} 生成。可用:${availableCodes.join("、") || "(无)"}`,
2711
+ });
2712
+ }
2713
+ const existing = (row.buttonPermission ?? {});
2714
+ const existingBtns = Array.isArray(existing.customBtns) ? existing.customBtns : [];
2715
+ const wanted = new Set(node.buttonCodes);
2716
+ const merged = [];
2717
+ for (const button of existingBtns) {
2718
+ const code = String(button.code ?? "");
2719
+ if (code === "")
2720
+ continue;
2721
+ merged.push({ ...button, enabled: wanted.has(code) });
2722
+ wanted.delete(code);
2723
+ }
2724
+ for (const code of wanted) {
2725
+ const source = available[code];
2726
+ names[code] = String(source.name ?? code);
2727
+ merged.push({ code, name: String(source.name ?? code), name_i18n: source.name_i18n ?? null, enabled: true, type: "form" });
2728
+ }
2729
+ patches.push({
2661
2730
  activityCode: node.activityCode,
2662
- buttonCodes: unknown,
2663
- available: availableCodes,
2664
- message: `按钮 ${unknown.join("、")} 不在模型 ${args.schemaCode} 的表单按钮里;先 form.publish {buttons:[…]} 生成。可用:${availableCodes.join("、") || "(无)"}`,
2731
+ buttonPermission: {
2732
+ ...existing,
2733
+ containerCode: existing.containerCode ?? args.schemaCode,
2734
+ customBtns: merged,
2735
+ childTableBtns: existing.childTableBtns ?? [],
2736
+ },
2665
2737
  });
2666
2738
  }
2667
- const existing = (row.buttonPermission ?? {});
2668
- const existingBtns = Array.isArray(existing.customBtns) ? existing.customBtns : [];
2669
- const wanted = new Set(node.buttonCodes);
2670
- const merged = [];
2671
- for (const button of existingBtns) {
2672
- const code = String(button.code ?? "");
2673
- if (code === "")
2674
- continue;
2675
- merged.push({ ...button, enabled: wanted.has(code) });
2676
- wanted.delete(code);
2677
- }
2678
- for (const code of wanted) {
2679
- const source = available[code];
2680
- names[code] = String(source.name ?? code);
2681
- merged.push({ code, name: String(source.name ?? code), name_i18n: source.name_i18n ?? null, enabled: true, type: "form" });
2739
+ const body = await mergedTemplateBody(session, args.schemaCode, args.workflowCode, { activities: patches }, true);
2740
+ validateWorkflowRender(body, "workflow.setButtons");
2741
+ const performed = await wireCall(session, { path: ENDPOINTS.workflowUpdate, method: "POST", body });
2742
+ const shouldPublish = args.publish !== false;
2743
+ let publishMarked = false;
2744
+ if (shouldPublish) {
2745
+ const publishPerformed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
2746
+ assertWorkflowWriteAccepted(publishPerformed, "workflow.setButtons", args.workflowCode);
2747
+ const pub = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.workflowCode } });
2748
+ const published = (pub.data ?? null);
2749
+ publishMarked = published !== null && (published.templateType === "PUBLISH" || published.published === true);
2682
2750
  }
2683
- patches.push({
2684
- activityCode: node.activityCode,
2685
- buttonPermission: {
2686
- ...existing,
2687
- containerCode: existing.containerCode ?? args.schemaCode,
2688
- customBtns: merged,
2689
- childTableBtns: existing.childTableBtns ?? [],
2690
- },
2751
+ const readback = (await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.workflowCode } })).data;
2752
+ const nodeButtons = (readback ? readback.activities : draft.activities);
2753
+ const rows = Array.isArray(nodeButtons)
2754
+ ? nodeButtons
2755
+ : nodeButtons && typeof nodeButtons === "object"
2756
+ ? Object.values(nodeButtons)
2757
+ : [];
2758
+ const state = args.nodes.map((node) => {
2759
+ const row = rows.find((entry) => String(entry.activityCode ?? "") === node.activityCode) ?? {};
2760
+ const bp = (row.buttonPermission ?? {});
2761
+ const btns = Array.isArray(bp.customBtns) ? bp.customBtns : [];
2762
+ return {
2763
+ activityCode: node.activityCode,
2764
+ enabled: btns.filter((button) => button.enabled === true).map((button) => String(button.code ?? "")),
2765
+ disabled: btns.filter((button) => button.enabled !== true).map((button) => String(button.code ?? "")),
2766
+ };
2691
2767
  });
2692
- }
2693
- const body = await mergedTemplateBody(session, args.schemaCode, args.workflowCode, { activities: patches }, true);
2694
- validateWorkflowRender(body, "workflow.setButtons");
2695
- const performed = await wireCall(session, { path: ENDPOINTS.workflowUpdate, method: "POST", body });
2696
- const shouldPublish = args.publish !== false;
2697
- let publishMarked = false;
2698
- if (shouldPublish) {
2699
- const publishPerformed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
2700
- assertWorkflowWriteAccepted(publishPerformed, "workflow.setButtons", args.workflowCode);
2701
- const pub = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: args.workflowCode } });
2702
- const published = (pub.data ?? null);
2703
- publishMarked = published !== null && (published.templateType === "PUBLISH" || published.published === true);
2704
- }
2705
- const readback = (await wireCall(session, { path: ENDPOINTS.workflowGetDraft, query: { workflowCode: args.workflowCode } })).data;
2706
- const nodeButtons = (readback ? readback.activities : draft.activities);
2707
- const rows = Array.isArray(nodeButtons)
2708
- ? nodeButtons
2709
- : nodeButtons && typeof nodeButtons === "object"
2710
- ? Object.values(nodeButtons)
2711
- : [];
2712
- const state = args.nodes.map((node) => {
2713
- const row = rows.find((entry) => String(entry.activityCode ?? "") === node.activityCode) ?? {};
2714
- const bp = (row.buttonPermission ?? {});
2715
- const btns = Array.isArray(bp.customBtns) ? bp.customBtns : [];
2716
2768
  return {
2717
- activityCode: node.activityCode,
2718
- enabled: btns.filter((button) => button.enabled === true).map((button) => String(button.code ?? "")),
2719
- disabled: btns.filter((button) => button.enabled !== true).map((button) => String(button.code ?? "")),
2769
+ performed: { errcode: performed.errcode, publishRequested: shouldPublish, nodes: args.nodes.map((node) => node.activityCode) },
2770
+ readback: {
2771
+ workflowCode: args.workflowCode,
2772
+ nodes: state,
2773
+ published: publishMarked,
2774
+ buttonNames: names,
2775
+ note: "节点未列出的按钮被置为 enabled:false(不删除子表按钮配置);表单页只在 enabled:true 的节点显示该按钮",
2776
+ },
2720
2777
  };
2721
- });
2722
- return {
2723
- performed: { errcode: performed.errcode, publishRequested: shouldPublish, nodes: args.nodes.map((node) => node.activityCode) },
2724
- readback: {
2725
- workflowCode: args.workflowCode,
2726
- nodes: state,
2727
- published: publishMarked,
2728
- buttonNames: names,
2729
- note: "节点未列出的按钮被置为 enabled:false(不删除子表按钮配置);表单页只在 enabled:true 的节点显示该按钮",
2730
- },
2731
- };
2732
- }, undefined, undefined, workflowRollbackSpec("workflow.update"));
2733
- 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).describe("流程编码——本参数名是 code") }, async (session, args) => {
2734
- let performed;
2735
- try {
2736
- performed = await wireCall(session, { path: ENDPOINTS.workflowDelete, method: "POST", query: { workflowCode: args.code } });
2737
- }
2738
- catch (error) {
2739
- // 402508 流程实例已存在 — the platform refuses on purpose. Previously it
2740
- // arrived as a bare wire code, so the caller's next move was to delete the
2741
- // MODEL instead (which cascades the template away and leaves orphan todos
2742
- // pointing at nothing — r14's 8× 50000). Name the order.
2743
- if (error instanceof WireError && error.kind === "business" && error.errcode === 402508) {
2744
- const pending = await countPendingWorkItems(session, args.code);
2745
- throw new ToolRefusalError({
2746
- status: "refused",
2747
- blocker: "workflow-has-instances",
2748
- workflowCode: args.code,
2749
- ...(pending > 0 ? { pendingWorkItems: pending } : {}),
2750
- nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowCode: args.code })}'`,
2751
- message: `流程 ${args.code} 还有流程实例,平台拒绝删除(402508)。**不要改用 model.delete 绕过**——那会把流程模板级联删掉,` +
2752
- `留下指向空模板的待办,而平台全局待办搜索会给每条待办解析模板,**一条缺失就整页 NPE(50000),所有用户的待办列表一起坏掉**(r14 实测 8 连击,需维护者清库)。` +
2753
- `正确顺序:① 先完结/清理实例的待办(workitem.list --workflowCode ${args.code} 看还有哪些在办);② 再 workflow.delete;③ 最后才 model.delete。`,
2754
- });
2778
+ },
2779
+ rollback: workflowRollbackSpec("workflow.update"),
2780
+ });
2781
+ writeTool({
2782
+ tools,
2783
+ name: "workflow.delete",
2784
+ lane: WORKFLOW_LANE,
2785
+ scope: WORKFLOW_TOOLS["workflow.delete"].scope,
2786
+ description: WORKFLOW_TOOLS["workflow.delete"].description,
2787
+ inputSchema: { schemaCode: z.string().min(1), code: z.string().min(1).describe("流程编码——本参数名是 code") },
2788
+ execute: async (session, args) => {
2789
+ let performed;
2790
+ try {
2791
+ performed = await wireCall(session, { path: ENDPOINTS.workflowDelete, method: "POST", query: { workflowCode: args.code } });
2755
2792
  }
2756
- throw error;
2757
- }
2758
- const header = await wireCall(session, { path: ENDPOINTS.workflowGetHeader, query: { workflowCode: args.code } }).catch(() => ({ data: null }));
2759
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, absent: header.data === null || header.data === undefined } };
2760
- }, undefined, undefined, workflowRollbackSpec("workflow.delete"));
2793
+ catch (error) {
2794
+ // 402508 流程实例已存在 — the platform refuses on purpose. Previously it
2795
+ // arrived as a bare wire code, so the caller's next move was to delete the
2796
+ // MODEL instead (which cascades the template away and leaves orphan todos
2797
+ // pointing at nothing — r14's 8× 50000). Name the order.
2798
+ if (error instanceof WireError && error.kind === "business" && error.errcode === 402508) {
2799
+ const pending = await countPendingWorkItems(session, args.code);
2800
+ throw new ToolRefusalError({
2801
+ status: "refused",
2802
+ blocker: "workflow-has-instances",
2803
+ workflowCode: args.code,
2804
+ ...(pending > 0 ? { pendingWorkItems: pending } : {}),
2805
+ nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowCode: args.code })}'`,
2806
+ message: `流程 ${args.code} 还有流程实例,平台拒绝删除(402508)。**不要改用 model.delete 绕过**——那会把流程模板级联删掉,` +
2807
+ `留下指向空模板的待办,而平台全局待办搜索会给每条待办解析模板,**一条缺失就整页 NPE(50000),所有用户的待办列表一起坏掉**(r14 实测 8 连击,需维护者清库)。` +
2808
+ `正确顺序:① 先完结/清理实例的待办(workitem.list --workflowCode ${args.code} 看还有哪些在办);② 再 workflow.delete;③ 最后才 model.delete。`,
2809
+ });
2810
+ }
2811
+ throw error;
2812
+ }
2813
+ const header = await wireCall(session, { path: ENDPOINTS.workflowGetHeader, query: { workflowCode: args.code } }).catch(() => ({ data: null }));
2814
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, absent: header.data === null || header.data === undefined } };
2815
+ },
2816
+ rollback: workflowRollbackSpec("workflow.delete"),
2817
+ });
2761
2818
  // ---- high-level greenfield orchestration (round 10 ergonomics) --------
2762
2819
  // The survey's write-face gap: a first workflow took create → update →
2763
2820
  // publish (+ bindRule for rules), 3–4 separate CLI calls. configure_workflow
@@ -3101,103 +3158,119 @@ export function registerWorkflowLane(tools) {
3101
3158
  };
3102
3159
  }),
3103
3160
  }));
3104
- writeTool(tools, "workflow.addPermission", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.addPermission"].scope, WORKFLOW_TOOLS["workflow.addPermission"].description, {
3105
- workflowCode: z.string().optional().describe("流程 code;省略则用 schemaCode 解析"),
3106
- schemaCode: z.string().optional().describe("模型 code:省略 workflowCode 时用它解析该模型的流程"),
3107
- unitId: z.string().min(1).describe("部门 id / 角色 id / 用户 id(看 unitType)"),
3108
- unitType: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal("DEPARTMENT"), z.literal("ROLE"), z.literal("USER")]).describe("1=部门 2=角色 3=用户;也接受 DEPARTMENT/ROLE/USER"),
3109
- corpId: z.string().optional().describe("企业 id;省略则用当前会话企业"),
3110
- }, async (session, args) => {
3111
- const workflowCode = await resolveWorkflowArg(session, args);
3112
- if (!workflowCode)
3113
- throw new ToolRefusalError({ status: "refused", blocker: "workflow-not-found", message: `模型 ${String(args.schemaCode ?? "")} 没有流程;先 workflow.create` });
3114
- const unitType = args.unitType === 1 || args.unitType === "DEPARTMENT" ? 1 : args.unitType === 2 || args.unitType === "ROLE" ? 2 : 3;
3115
- // Idempotent retry: the platform answers 402003 WORKFLOW_PERMISSION_EXIST
3116
- // for a duplicate unitId, which would read as a failure. `list_permission`
3117
- // does NOT echo unitId (only id/name/unitType/fullName/code), so identity
3118
- // is decided by the platform's own refusal rather than a guessed pre-read.
3119
- const before = await permissionRows(session, workflowCode);
3120
- try {
3121
- // Send the unit's own name/code too: `list_permission` reads them back
3122
- // (it echoes `name`/`code`), which is the only way `workflow.permissions`
3123
- // can describe a row by more than its opaque permission id.
3124
- const identity = await unitIdentity(session, unitType, args.unitId);
3125
- await wireCall(session, {
3126
- path: ENDPOINTS.workflowCreatePermission,
3127
- method: "POST",
3128
- body: {
3129
- workflowCode,
3130
- unitId: args.unitId,
3131
- unitType,
3132
- corpId: args.corpId ?? session.enterpriseId(),
3133
- ...(identity.name ? { name: identity.name } : {}),
3134
- ...(identity.code ? { code: identity.code } : {}),
3135
- },
3136
- });
3137
- }
3138
- catch (error) {
3139
- const errcode = error.details?.errcode ?? error.errcode;
3140
- if (String(errcode ?? "") !== "402003")
3141
- throw new ToolRefusalError({
3142
- status: "refused",
3143
- blocker: "workflow-permission-write-failed",
3144
- workflowCode,
3145
- unitId: args.unitId,
3146
- unitType,
3147
- message: `create_permission 被平台拒绝(err=${String(errcode ?? "n/a")})。核对 unitId 是否真实存在(部门用 org.department.list、角色用 org.role.list、用户用 org.user.search),以及发起权限是否被企业策略禁用。原始错误:${error instanceof Error ? error.message : String(error)}`,
3148
- });
3149
- return {
3150
- performed: { skipped: "already-present" },
3151
- readback: { workflowCode, alreadyPresent: true, unitId: args.unitId, unitType, count: before.length, note: "该主体已有发起权限(平台 402003),未重复写入" },
3152
- };
3153
- }
3154
- const after = await permissionRows(session, workflowCode);
3155
- const created = after.find((row) => !before.some((old) => String(old.id ?? "") === String(row.id ?? ""))) ?? after[after.length - 1];
3156
- return {
3157
- performed: { errcode: 0 },
3158
- readback: {
3159
- workflowCode,
3160
- count: after.length,
3161
- permissionId: created?.id ?? null,
3162
- unitType: created?.unitType ?? unitType,
3163
- name: created?.name ?? null,
3164
- note: "发起权限已授予。若之前 data.submit 回 40009,现在应可发起;仍失败先 workflow.permissions 确认行存在,并核对发起人所属部门/角色。",
3165
- },
3166
- };
3167
- });
3168
- writeTool(tools, "workflow.removePermission", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.removePermission"].scope, WORKFLOW_TOOLS["workflow.removePermission"].description, {
3169
- permissionId: z.string().min(1).optional().describe("首选:workflow.permissions 返回的 id(唯一确定)"),
3170
- workflowCode: z.string().optional().describe("配合 unitId+unitType 使用(平台的 list_permission 不回 unitId,需按显示名解析)"),
3171
- unitId: z.string().min(1).optional().describe("部门/角色/用户 id;按 unitType 解析显示名后匹配权限行"),
3172
- unitType: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal("DEPARTMENT"), z.literal("ROLE"), z.literal("USER")]).optional().describe("unitId 的类型:1=部门 2=角色 3=用户"),
3173
- schemaCode: z.string().optional(),
3174
- }, async (session, args) => {
3175
- let permissionId = args.permissionId;
3176
- if (!permissionId) {
3177
- const workflowCode = await resolveWorkflowArg(session, { workflowCode: args.workflowCode, schemaCode: args.schemaCode });
3178
- if (!workflowCode || !args.unitId || args.unitType === undefined)
3179
- throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "workflow.removePermission", message: "需要 permissionId(推荐),或 workflowCode(/schemaCode)+ unitId + unitType" });
3161
+ writeTool({
3162
+ tools,
3163
+ name: "workflow.addPermission",
3164
+ lane: WORKFLOW_LANE,
3165
+ scope: WORKFLOW_TOOLS["workflow.addPermission"].scope,
3166
+ description: WORKFLOW_TOOLS["workflow.addPermission"].description,
3167
+ inputSchema: {
3168
+ workflowCode: z.string().optional().describe("流程 code;省略则用 schemaCode 解析"),
3169
+ schemaCode: z.string().optional().describe("模型 code:省略 workflowCode 时用它解析该模型的流程"),
3170
+ unitId: z.string().min(1).describe("部门 id / 角色 id / 用户 id(看 unitType)"),
3171
+ unitType: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal("DEPARTMENT"), z.literal("ROLE"), z.literal("USER")]).describe("1=部门 2=角色 3=用户;也接受 DEPARTMENT/ROLE/USER"),
3172
+ corpId: z.string().optional().describe("企业 id;省略则用当前会话企业"),
3173
+ },
3174
+ execute: async (session, args) => {
3175
+ const workflowCode = await resolveWorkflowArg(session, args);
3176
+ if (!workflowCode)
3177
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-not-found", message: `模型 ${String(args.schemaCode ?? "")} 没有流程;先 workflow.create` });
3180
3178
  const unitType = args.unitType === 1 || args.unitType === "DEPARTMENT" ? 1 : args.unitType === 2 || args.unitType === "ROLE" ? 2 : 3;
3181
- const identity = await unitIdentity(session, unitType, args.unitId);
3182
- const rows = await permissionRows(session, workflowCode);
3183
- const wantedType = String(unitType);
3184
- const hit = rows.find((row) => String(row.unitType ?? "") === wantedType && (String(row.id ?? "") === args.unitId ||
3185
- (identity.name !== null && String(row.name ?? "") === identity.name) ||
3186
- (identity.code !== null && String(row.code ?? "") === identity.code)));
3187
- if (!hit)
3188
- return {
3189
- performed: { skipped: "absent" },
3190
- readback: {
3179
+ // Idempotent retry: the platform answers 402003 WORKFLOW_PERMISSION_EXIST
3180
+ // for a duplicate unitId, which would read as a failure. `list_permission`
3181
+ // does NOT echo unitId (only id/name/unitType/fullName/code), so identity
3182
+ // is decided by the platform's own refusal rather than a guessed pre-read.
3183
+ const before = await permissionRows(session, workflowCode);
3184
+ try {
3185
+ // Send the unit's own name/code too: `list_permission` reads them back
3186
+ // (it echoes `name`/`code`), which is the only way `workflow.permissions`
3187
+ // can describe a row by more than its opaque permission id.
3188
+ const identity = await unitIdentity(session, unitType, args.unitId);
3189
+ await wireCall(session, {
3190
+ path: ENDPOINTS.workflowCreatePermission,
3191
+ method: "POST",
3192
+ body: {
3191
3193
  workflowCode,
3192
- removed: false,
3193
- note: `没有匹配该主体的权限行(幂等)。注意 list_permission 不回 unitId,匹配靠解析出的显示名「${identity.name ?? "?"}」;不确定时用 workflow.permissions 拿 id 再按 permissionId 删。`,
3194
- permissions: rows.map((row) => ({ id: row.id ?? null, name: row.name ?? null, unitType: row.unitType ?? null })),
3194
+ unitId: args.unitId,
3195
+ unitType,
3196
+ corpId: args.corpId ?? session.enterpriseId(),
3197
+ ...(identity.name ? { name: identity.name } : {}),
3198
+ ...(identity.code ? { code: identity.code } : {}),
3195
3199
  },
3200
+ });
3201
+ }
3202
+ catch (error) {
3203
+ const errcode = error.details?.errcode ?? error.errcode;
3204
+ if (String(errcode ?? "") !== "402003")
3205
+ throw new ToolRefusalError({
3206
+ status: "refused",
3207
+ blocker: "workflow-permission-write-failed",
3208
+ workflowCode,
3209
+ unitId: args.unitId,
3210
+ unitType,
3211
+ message: `create_permission 被平台拒绝(err=${String(errcode ?? "n/a")})。核对 unitId 是否真实存在(部门用 org.department.list、角色用 org.role.list、用户用 org.user.search),以及发起权限是否被企业策略禁用。原始错误:${error instanceof Error ? error.message : String(error)}`,
3212
+ });
3213
+ return {
3214
+ performed: { skipped: "already-present" },
3215
+ readback: { workflowCode, alreadyPresent: true, unitId: args.unitId, unitType, count: before.length, note: "该主体已有发起权限(平台 402003),未重复写入" },
3196
3216
  };
3197
- permissionId = String(hit.id ?? "");
3198
- }
3199
- await wireCall(session, { path: ENDPOINTS.workflowDeletePermission, method: "POST", query: { permissionId } });
3200
- return { performed: { errcode: 0 }, readback: { permissionId, removed: true } };
3217
+ }
3218
+ const after = await permissionRows(session, workflowCode);
3219
+ const created = after.find((row) => !before.some((old) => String(old.id ?? "") === String(row.id ?? ""))) ?? after[after.length - 1];
3220
+ return {
3221
+ performed: { errcode: 0 },
3222
+ readback: {
3223
+ workflowCode,
3224
+ count: after.length,
3225
+ permissionId: created?.id ?? null,
3226
+ unitType: created?.unitType ?? unitType,
3227
+ name: created?.name ?? null,
3228
+ note: "发起权限已授予。若之前 data.submit 回 40009,现在应可发起;仍失败先 workflow.permissions 确认行存在,并核对发起人所属部门/角色。",
3229
+ },
3230
+ };
3231
+ },
3232
+ });
3233
+ writeTool({
3234
+ tools,
3235
+ name: "workflow.removePermission",
3236
+ lane: WORKFLOW_LANE,
3237
+ scope: WORKFLOW_TOOLS["workflow.removePermission"].scope,
3238
+ description: WORKFLOW_TOOLS["workflow.removePermission"].description,
3239
+ inputSchema: {
3240
+ permissionId: z.string().min(1).optional().describe("首选:workflow.permissions 返回的 id(唯一确定)"),
3241
+ workflowCode: z.string().optional().describe("配合 unitId+unitType 使用(平台的 list_permission 不回 unitId,需按显示名解析)"),
3242
+ unitId: z.string().min(1).optional().describe("部门/角色/用户 id;按 unitType 解析显示名后匹配权限行"),
3243
+ unitType: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal("DEPARTMENT"), z.literal("ROLE"), z.literal("USER")]).optional().describe("unitId 的类型:1=部门 2=角色 3=用户"),
3244
+ schemaCode: z.string().optional(),
3245
+ },
3246
+ execute: async (session, args) => {
3247
+ let permissionId = args.permissionId;
3248
+ if (!permissionId) {
3249
+ const workflowCode = await resolveWorkflowArg(session, { workflowCode: args.workflowCode, schemaCode: args.schemaCode });
3250
+ if (!workflowCode || !args.unitId || args.unitType === undefined)
3251
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "workflow.removePermission", message: "需要 permissionId(推荐),或 workflowCode(/schemaCode)+ unitId + unitType" });
3252
+ const unitType = args.unitType === 1 || args.unitType === "DEPARTMENT" ? 1 : args.unitType === 2 || args.unitType === "ROLE" ? 2 : 3;
3253
+ const identity = await unitIdentity(session, unitType, args.unitId);
3254
+ const rows = await permissionRows(session, workflowCode);
3255
+ const wantedType = String(unitType);
3256
+ const hit = rows.find((row) => String(row.unitType ?? "") === wantedType && (String(row.id ?? "") === args.unitId ||
3257
+ (identity.name !== null && String(row.name ?? "") === identity.name) ||
3258
+ (identity.code !== null && String(row.code ?? "") === identity.code)));
3259
+ if (!hit)
3260
+ return {
3261
+ performed: { skipped: "absent" },
3262
+ readback: {
3263
+ workflowCode,
3264
+ removed: false,
3265
+ note: `没有匹配该主体的权限行(幂等)。注意 list_permission 不回 unitId,匹配靠解析出的显示名「${identity.name ?? "?"}」;不确定时用 workflow.permissions 拿 id 再按 permissionId 删。`,
3266
+ permissions: rows.map((row) => ({ id: row.id ?? null, name: row.name ?? null, unitType: row.unitType ?? null })),
3267
+ },
3268
+ };
3269
+ permissionId = String(hit.id ?? "");
3270
+ }
3271
+ await wireCall(session, { path: ENDPOINTS.workflowDeletePermission, method: "POST", query: { permissionId } });
3272
+ return { performed: { errcode: 0 }, readback: { permissionId, removed: true } };
3273
+ },
3201
3274
  });
3202
3275
  tools.push(defineTool({
3203
3276
  name: "org.role.list",
@@ -3261,6 +3334,23 @@ export function registerWorkflowLane(tools) {
3261
3334
  parentId: dept.parentId ?? null,
3262
3335
  }));
3263
3336
  return { count: departments.length, departments };
3337
+ }, {
3338
+ // r28h F2: default CODE_NAME_LIST_KEYS collapses departments to
3339
+ // `id|name` and drops parentId — the one field this tool exists for.
3340
+ // Keep a dedicated slim shape that preserves hierarchy.
3341
+ compact: (result) => {
3342
+ const rows = Array.isArray(result.departments) ? result.departments : [];
3343
+ return {
3344
+ count: result.count ?? rows.length,
3345
+ departments: rows.map((row) => ({
3346
+ id: row.id ?? null,
3347
+ name: row.name ?? null,
3348
+ code: row.code ?? null,
3349
+ parentId: row.parentId ?? null,
3350
+ })),
3351
+ departmentsCount: rows.length,
3352
+ };
3353
+ },
3264
3354
  }),
3265
3355
  }));
3266
3356
  tools.push(defineTool({
@@ -3290,6 +3380,29 @@ export function registerWorkflowLane(tools) {
3290
3380
  // `wd` = 0..9 to discover users — 11 calls, ~16 KB, to learn a roster the
3291
3381
  // platform can already answer. Omitted keyword now aggregates
3292
3382
  // `department/list_user` over the department tree (one tool call).
3383
+ // r28h F4: ≤50 users inline as {id,name,username,departmentId}; larger
3384
+ // rosters fall back to count + artifact. Default summarizeValue was folding
3385
+ // a 12-person roster into `{count,sha256,omitted:true}`.
3386
+ const userSearchCompact = (result) => {
3387
+ const rows = Array.isArray(result.users) ? result.users : [];
3388
+ const slim = rows.map((row) => ({
3389
+ id: row.id ?? null,
3390
+ name: row.name ?? null,
3391
+ username: row.username ?? null,
3392
+ departmentId: row.departmentId ?? null,
3393
+ }));
3394
+ return {
3395
+ keyword: result.keyword ?? null,
3396
+ count: result.count ?? rows.length,
3397
+ total: result.total ?? rows.length,
3398
+ ...(result.departmentsScanned !== undefined ? { departmentsScanned: result.departmentsScanned } : {}),
3399
+ ...(result.truncated === true ? { truncated: true } : {}),
3400
+ ...(typeof result.note === "string" ? { note: result.note } : {}),
3401
+ ...(typeof result.hint === "string" ? { hint: result.hint } : {}),
3402
+ users: slim.length <= 50 ? slim : { count: slim.length, omitted: true },
3403
+ ...(slim.length > 50 ? { usersCount: slim.length } : {}),
3404
+ };
3405
+ };
3293
3406
  if (!keyword) {
3294
3407
  return governedRead(ctx, self, args, async (session) => {
3295
3408
  const orgQuery = { corpId: session.enterpriseId() };
@@ -3325,7 +3438,7 @@ export function registerWorkflowLane(tools) {
3325
3438
  users,
3326
3439
  hint: "无关键字=列全员(按部门树聚合)。平台 user/search 的 wd 不支持空串(10005),所以这条路径走 department/list_user;只要某部门的人用 org.department.users。",
3327
3440
  };
3328
- });
3441
+ }, { compact: userSearchCompact });
3329
3442
  }
3330
3443
  return governedRead(ctx, self, { ...args, wd: keyword }, async (session) => {
3331
3444
  const body = { wd: keyword, page: args.page ?? 0, size: args.size ?? 100 };
@@ -3344,7 +3457,7 @@ export function registerWorkflowLane(tools) {
3344
3457
  status: user.status ?? null,
3345
3458
  }));
3346
3459
  return { keyword, count: users.length, total: data?.totalElements ?? users.length, users };
3347
- });
3460
+ }, { compact: userSearchCompact });
3348
3461
  },
3349
3462
  }));
3350
3463
  }
@@ -3380,25 +3493,30 @@ function isRoleGroup(row) {
3380
3493
  export function flattenRoleTree(rows) {
3381
3494
  const roles = [];
3382
3495
  const groups = [];
3383
- const walk = (nodes, containerPath) => {
3496
+ const walk = (nodes, containers) => {
3384
3497
  for (const node of nodes) {
3385
3498
  if (!isRoleGroup(node)) {
3386
- roles.push(roleOf(node, containerPath.length > 0 ? containerPath[containerPath.length - 1] : null));
3499
+ const parent = containers.length > 0 ? containers[containers.length - 1] : null;
3500
+ roles.push(roleOf(node, parent));
3387
3501
  continue;
3388
3502
  }
3389
3503
  const name = String(node.name ?? "");
3390
- const path = [...containerPath, name];
3504
+ const id = String(node.id ?? "");
3505
+ const code = node.code == null || node.code === "" ? null : String(node.code);
3506
+ const path = [...containers.map((container) => container.name), name].filter((part) => part.length > 0).join(" / ");
3391
3507
  const children = roleChildren(node);
3508
+ const parent = containers.length > 0 ? containers[containers.length - 1] : null;
3392
3509
  groups.push({
3393
3510
  id: node.id ?? null,
3394
3511
  name: node.name ?? null,
3395
3512
  code: node.code ?? null,
3396
3513
  corpId: node.corpId ?? null,
3397
- path: path.join(" / "),
3514
+ parentId: parent?.id ?? null,
3515
+ path,
3398
3516
  childCount: children.length,
3399
3517
  });
3400
3518
  if (children.length > 0)
3401
- walk(children, path);
3519
+ walk(children, [...containers, { id, code, name }]);
3402
3520
  }
3403
3521
  };
3404
3522
  walk(rows, []);
@@ -3418,13 +3536,22 @@ export function flattenRoleTree(rows) {
3418
3536
  };
3419
3537
  }
3420
3538
  function roleOf(row, group) {
3539
+ // r28h F6: roles used to carry only `group:<name>` — write faces need the
3540
+ // group id/code. Keep `group` (name) for back-compat and add explicit FKs.
3421
3541
  return {
3422
3542
  id: row.id ?? null,
3423
3543
  name: row.name ?? null,
3424
3544
  code: row.code ?? null,
3425
3545
  roleType: row.roleType ?? null,
3426
3546
  corpId: row.corpId ?? null,
3427
- ...(group !== null ? { group } : {}),
3547
+ ...(group !== null
3548
+ ? {
3549
+ group: group.name,
3550
+ groupId: group.id || null,
3551
+ groupCode: group.code,
3552
+ groupName: group.name,
3553
+ }
3554
+ : {}),
3428
3555
  };
3429
3556
  }
3430
3557
  //# sourceMappingURL=workflow.js.map