@stalfh233/omc-cli 0.6.11 → 0.6.13

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 (58) hide show
  1. package/coverage/m1-coverage-manifest-v1.json +2 -0
  2. package/dist/approval-flow.js +84 -3
  3. package/dist/batch-call.js +32 -2
  4. package/dist/bench/call-face-eval.js +2 -2
  5. package/dist/call.js +35 -2
  6. package/dist/capability-overview.js +21 -7
  7. package/dist/cli-contract.js +34 -4
  8. package/dist/cli-output.js +6 -2
  9. package/dist/cli.js +303 -69
  10. package/dist/describe-cache.js +8 -4
  11. package/dist/environment-policy.js +20 -0
  12. package/dist/field-families.js +62 -4
  13. package/dist/form-support.js +293 -6
  14. package/dist/goal-routes.js +137 -22
  15. package/dist/identity.js +63 -11
  16. package/dist/lanes/app-contract.js +4 -4
  17. package/dist/lanes/app.js +19 -1
  18. package/dist/lanes/dictionary-contract.js +16 -5
  19. package/dist/lanes/dictionary-coverage.js +1 -0
  20. package/dist/lanes/dictionary.js +200 -8
  21. package/dist/lanes/environment-contract.js +18 -6
  22. package/dist/lanes/environment.js +129 -16
  23. package/dist/lanes/field-contract.js +2 -1
  24. package/dist/lanes/field.js +707 -59
  25. package/dist/lanes/form.js +52 -6
  26. package/dist/lanes/list-view-contract.js +2 -2
  27. package/dist/lanes/list-view.js +98 -2
  28. package/dist/lanes/meta.js +36 -10
  29. package/dist/lanes/model-contract.js +7 -6
  30. package/dist/lanes/model.js +154 -10
  31. package/dist/lanes/organization.js +67 -7
  32. package/dist/lanes/runtime-data-contract.js +4 -2
  33. package/dist/lanes/runtime-data.js +395 -41
  34. package/dist/lanes/workflow-contract.js +2 -2
  35. package/dist/lanes/workflow.js +381 -11
  36. package/dist/model-scaffold.js +11 -3
  37. package/dist/path-projection.js +192 -32
  38. package/dist/read-shapes.js +31 -11
  39. package/dist/recipes/field.js +16 -3
  40. package/dist/recipes/form.js +2 -1
  41. package/dist/recipes/runtime-data.js +58 -8
  42. package/dist/recipes/workflow.js +86 -1
  43. package/dist/refusal-guidance.js +18 -6
  44. package/dist/rollback-spec.js +41 -2
  45. package/dist/rollback.js +49 -11
  46. package/dist/script.js +72 -12
  47. package/dist/skills.js +91 -3
  48. package/dist/tools.js +113 -1
  49. package/dist/wire.js +13 -1
  50. package/dist/write-lock.js +92 -4
  51. package/dist/write.js +129 -16
  52. package/package.json +5 -3
  53. package/skills/omc-business-configuration.md +1 -1
  54. package/skills/omc-capabilities.md +5 -4
  55. package/skills/omc-playbook.md +41 -0
  56. package/skills/omc-refusals.md +58 -6
  57. package/skills/omc-tool-selection.md +2 -0
  58. package/skills/omc-write-hazards.md +3 -2
@@ -83,7 +83,7 @@ export const WORKFLOW_WIRE = Object.freeze({
83
83
  /** Workflow template object (workflow/update|publish raw body). */
84
84
  export const workflowTemplateArg = z
85
85
  .record(z.string(), z.unknown())
86
- .describe(`workflow template object. Minimal valid shape (create via workflow.create first, then workflow.update with this, then workflow.publish): ${WORKFLOW_WIRE.templateShape}. Add approval activities between start/end (each with id/type/participant) and routes[] as needed. **连线写法**:routes(recipe)/ edges(同义)/ rules(平台 DTO 字段名,get_draft 原样读回)三选一,同传两种会被拒。**新节点必须带 type/activityType**(activityCode 命中草稿既有节点时可省)。**体积**:整模板含 propertyPermissions 常到 1.0–1.4MB,而 --args-file 上限 1MB(CLI_ARGS_MAX_BYTES=1048576)——只发改动节点/权限行,别回传整份正文。 **参与人解析陷阱(r5-crm 实证 2026-10-10)**:内置 FindUserByRole({Originator.OU},code,...) 按部门范围找人——目标角色**不挂部门**(如全公司唯一的总监)时 ParticipantActivityRuntime.initParticipants 抛 EngineRuntimeException、流程卡 PROCESSING 无待办;跨部门/全局角色**不要写 FindUsersSpecByRoleFunction(code)**——1.1.25 运行期未注册该函数(FunctionNotFoundException,接口仍回 errCode 0),改用参与者对象数组(角色 id)或给角色补上可覆盖的部门范围成员。解析为空(零成员/未挂部门/自由文本/角色码不存在)时引擎**把任务退回发起人**、流程继续,症状是「审批人成了我自己」,不是卡住;真正卡住的是参与人函数未注册(FunctionNotFoundException,无待办、PROCESSING、接口仍 errCode 0)。两者都用 workflow.instanceActivity 区分:无 status=1 节点=卡住;status=1 且参与人全是发起人=静默错派(r4-training 实证 2026-10-20)。 **START 之后必须保留发起节点**(如「填写申请单」participant={Originator},删掉/缺失则 publish 401035——r5-crm 实证 2026-10-09);流程模型的数据入口=直接 data.submit(创建+启流程+单据号生成,勿用 save+submit 两步)。 A route entry MAY carry the platform's structured branch condition ruleConditionGroup (outer OR / inner AND) plus childMatchType/utilizeElse/alternativeFlag/text — see the workflow contract's routeBranch: ${WORKFLOW_WIRE.routeBranch}. endEventHandler{bizActions,bizActionType} binds business rules — prefer workflow.bindRule instead of replacing the whole template(节点级事件用 bindRule 的 activityCode+nodeEvent)。`);
86
+ .describe(`workflow template object. Minimal valid shape (create via workflow.create first, then workflow.update with this, then workflow.publish): ${WORKFLOW_WIRE.templateShape}. **连线写法**:routes(recipe)/ edges(同义)/ rules(平台 DTO 字段名,get_draft 原样读回)三选一,同传两种会被拒。**新节点必须带 type/activityType**(activityCode 命中草稿既有节点时可省)。**体积**:整模板含 propertyPermissions 常到 1.0–1.4MB,而 --args-file 上限 1MB(CLI_ARGS_MAX_BYTES=1048576)——只发改动节点/权限行,别回传整份正文。 **参与人解析陷阱(r5-crm 实证 2026-10-10)**:内置 FindUserByRole({Originator.OU},code,...) 按部门范围找人——目标角色**不挂部门**(如全公司唯一的总监)时 ParticipantActivityRuntime.initParticipants 抛 EngineRuntimeException、流程卡 PROCESSING 无待办;跨部门/全局角色**不要写 FindUsersSpecByRoleFunction(code)**——1.1.25 运行期未注册该函数(FunctionNotFoundException,接口仍回 errCode 0),改用参与者对象数组(角色 id)或给角色补上可覆盖的部门范围成员。解析为空(零成员/未挂部门/自由文本/角色码不存在)时引擎**把任务退回发起人**、流程继续,症状「审批人成了我自己」;真正卡住是参与人函数未注册(无待办/PROCESSING/errcode 0)。用 workflow.instanceActivity 区分:无 status=1 节点=卡住;status=1 且参与人全是发起人=静默错派(r4-training 2026-10-20)。 **START 之后必须保留发起节点**(如「填写申请单」participant={Originator},删掉/缺失则 publish 401035——r5-crm 实证 2026-10-09);流程模型的数据入口=直接 data.submit(创建+启流程+单据号生成,勿用 save+submit 两步)。 routeBranch: ${WORKFLOW_WIRE.routeBranch}。endEventHandler{bizActions,bizActionType} 绑业务规则——用 workflow.bindRule(activityCode+nodeEvent),别替换整个模板。`);
87
87
  const WORKFLOW_SCOPE = "greenfield workflow authoring";
88
88
  export const WORKFLOW_LANE_CONTRACT = Object.freeze({
89
89
  lane: LANES.workflow,
@@ -176,7 +176,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
176
176
  }),
177
177
  "workflow.permissions": Object.freeze({
178
178
  scope: "workflow start permission (who may originate)",
179
- description: "列出谁能发起该流程(`GET /api/api/workflow/list_permission?workflowCode=` → {id,name,unitType,fullName,corpId,code};1=部门 2=角色 3=用户)。**`data.submit` 报 40009 时先看这里**:PART_VISIABLE 且本表无命中行 ⇒ 除平台管理员(`h_perm_admin` 的 ADMIN/SYS_MNG,非登录 isAdmin)外无人能发起;发布流程不会自动建这一行。",
179
+ description: "列出谁能发起该流程(`GET /api/api/workflow/list_permission` → {id,name,unitType,fullName,corpId,code};1=部门 2=角色 3=用户;平台权限枚举 PART_VISIABLE)。回执带 `actor`/`effectiveFor`:管理员绕过白名单时 reason 标明,发起成功≠白名单生效。40009 先看这里;发布不会自动建行。",
180
180
  }),
181
181
  "workflow.addPermission": Object.freeze({
182
182
  scope: "workflow start permission (who may originate)",
@@ -14,6 +14,7 @@ import { readSchemaFormButtons } from "../form-support.js";
14
14
  import { WORKFLOW_EVENT_HANDLER_FIELDS, WORKFLOW_NODE_EVENT_HANDLER_FIELDS, WORKFLOW_LANE_CONTRACT, workflowTemplateArg } from "./workflow-contract.js";
15
15
  import { PLATFORM_TRIGGER_RULE_CODES } from "./rule-graph.js";
16
16
  import { executeRuleEnableWrite } from "./rule-lifecycle.js";
17
+ import { PLATFORM_ADMIN_START_BYPASS, resolvePermissionActor } from "../identity.js";
17
18
  const WORKFLOW_LANE = WORKFLOW_LANE_CONTRACT.lane;
18
19
  const WORKFLOW_TOOLS = WORKFLOW_LANE_CONTRACT.tools;
19
20
  const WORKFLOW_ACTIVITY_TYPES = new Set(["START", "END", "CONNECTION", "PARTICIPANT", "SUB_INSTANCE", "CIRCULATE", "SYSTEM_ACTIVITY", "AGENT_ACTIVITY"]);
@@ -1015,6 +1016,106 @@ function pickRouteSpelling(spellings, tool) {
1015
1016
  * chain order" — reusing any edge whose endpoints survived. A draft whose
1016
1017
  * (possibly branching) graph is untouched passes the chain check verbatim.
1017
1018
  */
1019
+ /**
1020
+ * r31c F23: local reachability / else-coverage warnings for workflow.validate.
1021
+ * Does NOT change `valid` (still ERROR-only). A node with only conditional
1022
+ * outbounds and no utilizeElse/unconditional edge is a dead-end when the
1023
+ * condition fails — platform validate still says valid:true.
1024
+ */
1025
+ export function workflowReachabilityWarnings(template) {
1026
+ const activitiesRaw = template.activities;
1027
+ const activities = Array.isArray(activitiesRaw)
1028
+ ? activitiesRaw
1029
+ : activitiesRaw && typeof activitiesRaw === "object"
1030
+ ? Object.values(activitiesRaw)
1031
+ : [];
1032
+ if (activities.length === 0)
1033
+ return [];
1034
+ const rulesRaw = template.rules ?? template.routes ?? template.edges;
1035
+ const rules = Array.isArray(rulesRaw) ? rulesRaw : [];
1036
+ const typeOf = new Map();
1037
+ const nameOf = new Map();
1038
+ for (const activity of activities) {
1039
+ const code = String(activity.activityCode ?? "").trim();
1040
+ if (!code)
1041
+ continue;
1042
+ typeOf.set(code, String(activity.activityType ?? "").trim());
1043
+ nameOf.set(code, String(activity.activityName ?? activity.name ?? code));
1044
+ }
1045
+ const outbound = new Map();
1046
+ for (const rule of rules) {
1047
+ const pre = String(rule.preActivityCode ?? rule.preNode ?? rule.pre ?? rule.from ?? "").trim();
1048
+ const post = String(rule.postActivityCode ?? rule.postNode ?? rule.post ?? rule.to ?? "").trim();
1049
+ if (!pre || !post)
1050
+ continue;
1051
+ const hasGroup = Array.isArray(rule.ruleConditionGroup) && rule.ruleConditionGroup.length > 0;
1052
+ const utilizeElse = rule.utilizeElse === true;
1053
+ const list = outbound.get(pre) ?? [];
1054
+ list.push({ post, conditional: hasGroup && !utilizeElse, utilizeElse });
1055
+ outbound.set(pre, list);
1056
+ }
1057
+ const warnings = [];
1058
+ // 1) Nodes with conditional outbounds must also have an else / unconditional exit.
1059
+ for (const [code, edges] of outbound) {
1060
+ if (typeOf.get(code) === "END")
1061
+ continue;
1062
+ const hasConditional = edges.some((edge) => edge.conditional);
1063
+ if (!hasConditional)
1064
+ continue;
1065
+ const hasFallback = edges.some((edge) => edge.utilizeElse || !edge.conditional);
1066
+ if (!hasFallback) {
1067
+ warnings.push({
1068
+ activityCode: code,
1069
+ name: nameOf.get(code) ?? code,
1070
+ problem: `节点 ${code} 在条件不满足时无出路(流程断头):有条件出边但没有 utilizeElse/无条件兜底`,
1071
+ });
1072
+ }
1073
+ }
1074
+ // 2) Every non-END node must be able to reach some END along ANY outbound edge.
1075
+ const endCodes = [...typeOf.entries()].filter(([, type]) => type === "END").map(([code]) => code);
1076
+ if (endCodes.length === 0)
1077
+ return warnings;
1078
+ const canReachEnd = (start) => {
1079
+ const seen = new Set();
1080
+ const stack = [start];
1081
+ while (stack.length > 0) {
1082
+ const current = stack.pop();
1083
+ if (typeOf.get(current) === "END")
1084
+ return true;
1085
+ if (seen.has(current))
1086
+ continue;
1087
+ seen.add(current);
1088
+ for (const edge of outbound.get(current) ?? []) {
1089
+ if (!seen.has(edge.post))
1090
+ stack.push(edge.post);
1091
+ }
1092
+ }
1093
+ return false;
1094
+ };
1095
+ for (const [code, type] of typeOf) {
1096
+ if (type === "END")
1097
+ continue;
1098
+ if ((outbound.get(code) ?? []).length === 0) {
1099
+ // START with no edges is already a platform ERROR; still warn for PARTICIPANT dead-ends.
1100
+ if (type === "PARTICIPANT" || type === "SUB_INSTANCE" || type === "SYSTEM") {
1101
+ warnings.push({
1102
+ activityCode: code,
1103
+ name: nameOf.get(code) ?? code,
1104
+ problem: `节点 ${code} 没有任何出向边,无法到达 END(流程断头)`,
1105
+ });
1106
+ }
1107
+ continue;
1108
+ }
1109
+ if (!canReachEnd(code)) {
1110
+ warnings.push({
1111
+ activityCode: code,
1112
+ name: nameOf.get(code) ?? code,
1113
+ problem: `节点 ${code} 沿现有出向边无法到达 END(流程断头)`,
1114
+ });
1115
+ }
1116
+ }
1117
+ return warnings;
1118
+ }
1018
1119
  export function completeWorkflowChain(rules, activities) {
1019
1120
  const codes = activities.map((activity) => String(activity.activityCode ?? "")).filter((code) => code.length > 0);
1020
1121
  if (codes.length < 2)
@@ -1977,6 +2078,23 @@ export function workflowCompactProjection(result) {
1977
2078
  }
1978
2079
  }
1979
2080
  const originate = data.originateActivity;
2081
+ // Count ALL propertyPermissions rows on the native template (including
2082
+ // all-false), so --full/--jq can answer "did my visibility edit land?" even
2083
+ // when the compact permissions face filters defaults out.
2084
+ const propertyPermissionsCount = Object.values(activities).reduce((sum, activity) => {
2085
+ const rows = activity.propertyPermissions;
2086
+ return sum + (Array.isArray(rows) ? rows.length : 0);
2087
+ }, 0);
2088
+ // r30d F14: --full externalizes the native body; keep a projectable top-level
2089
+ // activities summary so callers do not need four hops (describe → get --full
2090
+ // → pathMiss → artifact get) just to see node codes / participants.
2091
+ const activitySummaries = nodes.map((node) => ({
2092
+ code: node.code,
2093
+ name: node.name,
2094
+ type: node.type,
2095
+ ...(node.participant !== undefined ? { participants: node.participant } : {}),
2096
+ ...(node.permissions !== undefined ? { propertyPermissions: node.permissions } : {}),
2097
+ }));
1980
2098
  return {
1981
2099
  workflowSummary: {
1982
2100
  workflowCode: data.workflowCode ?? result.workflowCode ?? null,
@@ -1988,11 +2106,15 @@ export function workflowCompactProjection(result) {
1988
2106
  ruleBindingCount: ruleBindings.reduce((sum, binding) => sum + binding.ruleCodes.length, 0),
1989
2107
  ...(originate ? { originateActivity: { code: originate.activityCode ?? null, type: originate.activityType ?? null, name: effectiveActivityName(originate), participant: originate.participant ?? null } } : {}),
1990
2108
  },
2109
+ // Projectable top-level face for --full / --jq (r30d F9/F14).
2110
+ activities: activitySummaries,
2111
+ propertyPermissionsCount,
2112
+ version: data.workflowVersion ?? data.version ?? null,
1991
2113
  // Editing needs only `activities` (array or index object) + `routes`: the tool
1992
2114
  // merges onto the stored draft, connects added nodes through to END, fills
1993
2115
  // sheetCode and geometry. Reading the 30–75 KB full body is NOT required —
1994
2116
  // bench17 pulled 61 KB `full:true` just to edit two node names.
1995
- summaryHint: "改流程只需把要改/新增的 activities(数组)+ routes 传给 workflow.update:缺 START/END 的部分模板会自动按增量 merge 到草稿(草稿为基,自动补 sheetCode/连线/坐标),无需读回整模板或 --full 正文;字段权限也只需在对应 PARTICIPANT 上给 propertyPermissions:[{propertyCode,...}],工具按 propertyCode 合并、未列的字段权限保留。只有要整图替换时才给全 START..END 的完整模板;要看通知等完整节点正文才用 detailArtifactId 或 --full",
2117
+ summaryHint: "改流程只需把要改/新增的 activities(数组)+ routes 传给 workflow.update:缺 START/END 的部分模板会自动按增量 merge 到草稿(草稿为基,自动补 sheetCode/连线/坐标),无需读回整模板或 --full 正文;字段权限也只需在对应 PARTICIPANT 上给 propertyPermissions:[{propertyCode,...}],工具按 propertyCode 合并、未列的字段权限保留。只有要整图替换时才给全 START..END 的完整模板;要看通知等完整节点正文才用 detailArtifactId 或 --full。--full 外置时顶层仍有 activities[{code,name,participants}] + propertyPermissionsCount 可直接投影。",
1996
2118
  };
1997
2119
  }
1998
2120
  /**
@@ -2223,7 +2345,28 @@ export function registerWorkflowLane(tools) {
2223
2345
  const messages = Array.isArray(result.data) ? result.data : [];
2224
2346
  const errors = messages.filter((message) => message.errorLevel === "ERROR");
2225
2347
  const warnings = messages.filter((message) => message.errorLevel !== "ERROR");
2226
- return { workflowCode, valid: errors.length === 0, errorCount: errors.length, warningCount: warnings.length, errors, warnings };
2348
+ // r31c F23: local reachability / else-coverage — warning only, never flips valid.
2349
+ const reachability = workflowReachabilityWarnings(body);
2350
+ const allWarnings = [
2351
+ ...warnings,
2352
+ ...reachability.map((entry) => ({
2353
+ errorLevel: "WARNING",
2354
+ errCode: "omc-reachability",
2355
+ errContent: entry.problem,
2356
+ activityCode: entry.activityCode,
2357
+ name: entry.name,
2358
+ problem: entry.problem,
2359
+ })),
2360
+ ];
2361
+ return {
2362
+ workflowCode,
2363
+ valid: errors.length === 0,
2364
+ errorCount: errors.length,
2365
+ warningCount: allWarnings.length,
2366
+ errors,
2367
+ warnings: allWarnings,
2368
+ ...(reachability.length > 0 ? { reachabilityWarnings: reachability } : {}),
2369
+ };
2227
2370
  }, { target: `workflow.validate:${args.workflowCode ?? args.schemaCode}` }),
2228
2371
  }));
2229
2372
  writeTool({
@@ -2808,6 +2951,51 @@ export function registerWorkflowLane(tools) {
2808
2951
  `正确顺序:① 先完结/清理实例的待办(workitem.list --workflowCode ${args.code} 看还有哪些在办);② 再 workflow.delete;③ 最后才 model.delete。`,
2809
2952
  });
2810
2953
  }
2954
+ // 303024 列表绑定了流程 — workflow.create auto-binds the default list
2955
+ // view; delete refuses until that WORKFLOW association is removed
2956
+ // (r30d F12/F13; r31 F48). Name the bound view + a pasteable unbind
2957
+ // command that already carries real fieldCodes (no listview.get dig).
2958
+ if (error instanceof WireError && error.kind === "business" && error.errcode === 303024) {
2959
+ const bound = await findListviewsBoundToWorkflow(session, args.schemaCode, args.code);
2960
+ const primary = bound[0];
2961
+ const viewCode = primary?.code ?? args.schemaCode;
2962
+ const fieldCodes = primary?.fieldCodes?.length
2963
+ ? primary.fieldCodes
2964
+ : ["createdTime", "creater", "name", "sequenceNo"];
2965
+ // Live-proven unbind (sandbox-r31f 2026-11-05): empty actions +
2966
+ // replaceActions:true clears the WORKFLOW add binding; platform then
2967
+ // re-injects non-workflow defaults. Prefer this over rewriting ADD→BIZSHEET
2968
+ // so the pasted command needs no action surgery.
2969
+ const detachArgs = {
2970
+ schemaCode: args.schemaCode,
2971
+ code: viewCode,
2972
+ fieldCodes,
2973
+ actions: [],
2974
+ replaceActions: true,
2975
+ };
2976
+ const detachExample = `omc call listview.configure --env <env> --args '${JSON.stringify(detachArgs)}'`;
2977
+ throw new ToolRefusalError({
2978
+ status: "refused",
2979
+ blocker: "workflow-bound-to-listview",
2980
+ errcode: 303024,
2981
+ workflowCode: args.code,
2982
+ schemaCode: args.schemaCode,
2983
+ boundListviews: bound.map(({ code, name, actionCode, associationCode, fieldCodes: cols }) => ({
2984
+ code,
2985
+ name,
2986
+ actionCode,
2987
+ associationCode,
2988
+ ...(cols.length > 0 ? { fieldCodes: cols } : {}),
2989
+ })),
2990
+ nextCommand: detachExample,
2991
+ message: `流程 ${args.code} 仍被列表视图绑定(303024「列表绑定了流程」)。` +
2992
+ (bound.length > 0
2993
+ ? `绑在:${bound.map((view) => `${view.code}(${view.name})action=${view.actionCode ?? "?"}`).join("、")}。`
2994
+ : `默认视图多半是 ${args.schemaCode}(workflow.create 会自动绑)。`) +
2995
+ `先解绑再删:粘贴 nextCommand(listview.configure:actions:[] + replaceActions:true + 现有 fieldCodes),再重试 workflow.delete。` +
2996
+ `整体清理也可用 omc model-purge --code ${args.schemaCode}(级联带走流程模板与视图)。`,
2997
+ });
2998
+ }
2811
2999
  throw error;
2812
3000
  }
2813
3001
  const header = await wireCall(session, { path: ENDPOINTS.workflowGetHeader, query: { workflowCode: args.code } }).catch(() => ({ data: null }));
@@ -3085,6 +3273,54 @@ export function registerWorkflowLane(tools) {
3085
3273
  // Publishing a flow creates NO permission row, so a freshly published flow is
3086
3274
  // unusable by anyone who is not a platform admin.
3087
3275
  const permissionRows = async (session, workflowCode) => wireRows((await wireCall(session, { path: ENDPOINTS.workflowListPermission, query: { workflowCode } })).data);
3276
+ /** Best-effort: which list views still bind this workflow (303024 recovery). */
3277
+ async function findListviewsBoundToWorkflow(session, schemaCode, workflowCode) {
3278
+ const bound = [];
3279
+ try {
3280
+ const listed = wireRows((await wireCall(session, { path: ENDPOINTS.listQueryList, query: { schemaCode, clientType: "PC" } })).data);
3281
+ for (const row of listed) {
3282
+ const code = String(row.code ?? "");
3283
+ if (!code)
3284
+ continue;
3285
+ try {
3286
+ const got = await wireCall(session, { path: ENDPOINTS.listQueryGet, query: { schemaCode, code, clientType: "PC" } });
3287
+ const model = (got.data ?? null);
3288
+ if (!model)
3289
+ continue;
3290
+ const actions = Array.isArray(model.queryActions) ? model.queryActions : [];
3291
+ const fieldCodes = (Array.isArray(model.queryColumns) ? model.queryColumns : [])
3292
+ .map((column) => String(column.propertyCode ?? ""))
3293
+ .filter(Boolean);
3294
+ for (const action of actions) {
3295
+ const association = action.associationType ?? action.queryActionRelativeType;
3296
+ const associationCode = String(action.associationCode ?? action.relativeCode ?? "");
3297
+ const isWorkflow = association === 1 ||
3298
+ association === "1" ||
3299
+ association === "WORKFLOW" ||
3300
+ String(association).toUpperCase() === "WORKFLOW";
3301
+ if (!isWorkflow)
3302
+ continue;
3303
+ if (associationCode && associationCode !== workflowCode && associationCode !== schemaCode)
3304
+ continue;
3305
+ bound.push({
3306
+ code,
3307
+ name: String(row.name ?? model.name ?? code),
3308
+ actionCode: action.actionCode === undefined || action.actionCode === null ? null : String(action.actionCode),
3309
+ associationCode: associationCode || null,
3310
+ fieldCodes,
3311
+ });
3312
+ }
3313
+ }
3314
+ catch {
3315
+ // one view unreadable — keep scanning
3316
+ }
3317
+ }
3318
+ }
3319
+ catch {
3320
+ // list unreadable — caller still gets a generic detach recipe
3321
+ }
3322
+ return bound;
3323
+ }
3088
3324
  // `list_permission` echoes the PERMISSION row id plus the unit's display name
3089
3325
  // and code — never the unitId (WorkflowTemplateController.getPermissionMap).
3090
3326
  // So identity for readback/removal is resolved from the unit's own face.
@@ -3144,17 +3380,35 @@ export function registerWorkflowLane(tools) {
3144
3380
  corpId: row.corpId ?? null,
3145
3381
  code: row.code ?? null,
3146
3382
  }));
3383
+ const actor = await resolvePermissionActor(session);
3384
+ const effectiveFor = {
3385
+ userId: actor.userId,
3386
+ name: actor.name,
3387
+ allowed: actor.isAdmin || permissions.length > 0,
3388
+ reason: actor.isAdmin
3389
+ ? PLATFORM_ADMIN_START_BYPASS
3390
+ : permissions.length === 0
3391
+ ? "no-start-permission-row"
3392
+ : "whitelist-configured-not-evaluated-for-membership",
3393
+ note: actor.isAdmin
3394
+ ? "当前登录人是平台管理员:发起成功不证明白名单生效。要用非管理员账号另建环境验证。"
3395
+ : "本读面只回配置行;membership 命中需非管理员实发 data.submit 才能证伪。",
3396
+ };
3147
3397
  return {
3148
3398
  workflowCode,
3149
3399
  count: permissions.length,
3150
3400
  permissions,
3401
+ actor,
3402
+ effectiveFor,
3151
3403
  ...(permissions.length === 0
3152
3404
  ? {
3153
3405
  blocker: "workflow-start-permission-empty",
3154
- note: "该流程没有任何发起权限行:除平台管理员(h_perm_admin 里 ADMIN/SYS_MNG)外**没有人能发起**,data.submit 会回 40009「流程发起权限不足」。发布流程不会自动建这一行,用 workflow.addPermission 授给部门/角色/用户(或把模板 visibleType 改成 ALL_VISIABLE)。",
3406
+ note: "该流程没有任何发起权限行:除平台管理员(h_perm_admin 里 ADMIN/SYS_MNG)外**没有人能发起**,data.submit 会回 40009「流程发起权限不足」。发布流程不会自动建这一行,用 workflow.addPermission 授给部门/角色/用户(或把模板 visibleType 改成 ALL_VISIABLE)。管理员发起成功 ≠ 白名单生效。",
3155
3407
  nextCommand: `omc call workflow.addPermission --env <env> --args '{"workflowCode":"${workflowCode}","unitId":"<部门|角色|用户 id>","unitType":1}'`,
3156
3408
  }
3157
- : {}),
3409
+ : {
3410
+ note: "permissions 是配置白名单。若 actor.isAdmin=true,data.submit 成功可能只是管理员绕过,不证明白名单对普通用户生效。",
3411
+ }),
3158
3412
  };
3159
3413
  }),
3160
3414
  }));
@@ -3210,13 +3464,15 @@ export function registerWorkflowLane(tools) {
3210
3464
  unitType,
3211
3465
  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
3466
  });
3467
+ const actor = await resolvePermissionActor(session);
3213
3468
  return {
3214
3469
  performed: { skipped: "already-present" },
3215
- readback: { workflowCode, alreadyPresent: true, unitId: args.unitId, unitType, count: before.length, note: "该主体已有发起权限(平台 402003),未重复写入" },
3470
+ readback: { workflowCode, alreadyPresent: true, unitId: args.unitId, unitType, count: before.length, note: "该主体已有发起权限(平台 402003),未重复写入", actor },
3216
3471
  };
3217
3472
  }
3218
3473
  const after = await permissionRows(session, workflowCode);
3219
3474
  const created = after.find((row) => !before.some((old) => String(old.id ?? "") === String(row.id ?? ""))) ?? after[after.length - 1];
3475
+ const actor = await resolvePermissionActor(session);
3220
3476
  return {
3221
3477
  performed: { errcode: 0 },
3222
3478
  readback: {
@@ -3225,10 +3481,28 @@ export function registerWorkflowLane(tools) {
3225
3481
  permissionId: created?.id ?? null,
3226
3482
  unitType: created?.unitType ?? unitType,
3227
3483
  name: created?.name ?? null,
3228
- note: "发起权限已授予。若之前 data.submit 回 40009,现在应可发起;仍失败先 workflow.permissions 确认行存在,并核对发起人所属部门/角色。",
3484
+ actor,
3485
+ note: "发起权限已授予。若之前 data.submit 回 40009,现在应可发起;仍失败先 workflow.permissions 确认行存在,并核对发起人所属部门/角色。管理员发起成功不证明白名单对普通人生效。",
3229
3486
  },
3230
3487
  };
3231
3488
  },
3489
+ rollback: {
3490
+ target: (args) => `workflow-permission:${String(args.workflowCode ?? args.schemaCode ?? "")}`,
3491
+ read: async (session, args) => {
3492
+ const workflowCode = await resolveWorkflowArg(session, { workflowCode: args.workflowCode, schemaCode: args.schemaCode });
3493
+ if (!workflowCode)
3494
+ return null;
3495
+ return permissionRows(session, workflowCode);
3496
+ },
3497
+ inverse: ({ before, args, readback }) => {
3498
+ if (readback.alreadyPresent === true)
3499
+ return { kind: "calls", calls: [], notes: ["权限行已存在(幂等),未写入,无需回滚"] };
3500
+ const permissionId = readback.permissionId;
3501
+ if (typeof permissionId === "string" && permissionId.length > 0)
3502
+ return { kind: "calls", calls: [{ tool: "workflow.removePermission", args: { permissionId } }] };
3503
+ return { kind: "irreversible", reason: "addPermission 回执缺少 permissionId,无法自动撤权;用 workflow.permissions 找 id 后手工 removePermission" };
3504
+ },
3505
+ },
3232
3506
  });
3233
3507
  writeTool({
3234
3508
  tools,
@@ -3245,8 +3519,11 @@ export function registerWorkflowLane(tools) {
3245
3519
  },
3246
3520
  execute: async (session, args) => {
3247
3521
  let permissionId = args.permissionId;
3522
+ let workflowCodeForActor;
3523
+ let beforeRow = null;
3248
3524
  if (!permissionId) {
3249
3525
  const workflowCode = await resolveWorkflowArg(session, { workflowCode: args.workflowCode, schemaCode: args.schemaCode });
3526
+ workflowCodeForActor = workflowCode ?? undefined;
3250
3527
  if (!workflowCode || !args.unitId || args.unitType === undefined)
3251
3528
  throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "workflow.removePermission", message: "需要 permissionId(推荐),或 workflowCode(/schemaCode)+ unitId + unitType" });
3252
3529
  const unitType = args.unitType === 1 || args.unitType === "DEPARTMENT" ? 1 : args.unitType === 2 || args.unitType === "ROLE" ? 2 : 3;
@@ -3254,22 +3531,115 @@ export function registerWorkflowLane(tools) {
3254
3531
  const rows = await permissionRows(session, workflowCode);
3255
3532
  const wantedType = String(unitType);
3256
3533
  const hit = rows.find((row) => String(row.unitType ?? "") === wantedType && (String(row.id ?? "") === args.unitId ||
3534
+ // list_permission echoes `code` (unit business code or, on some faces, the
3535
+ // unitId itself) but never unitId — accept both spellings so remove by
3536
+ // unitId does not silently miss (r30d F11 / fake+live).
3537
+ String(row.code ?? "") === args.unitId ||
3257
3538
  (identity.name !== null && String(row.name ?? "") === identity.name) ||
3258
- (identity.code !== null && String(row.code ?? "") === identity.code)));
3259
- if (!hit)
3539
+ (identity.code !== null && String(row.code ?? "") === identity.code) ||
3540
+ (identity.name !== null && String(row.fullName ?? "") === identity.name)));
3541
+ if (!hit) {
3542
+ const actor = await resolvePermissionActor(session);
3260
3543
  return {
3261
3544
  performed: { skipped: "absent" },
3262
3545
  readback: {
3263
3546
  workflowCode,
3264
3547
  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 })),
3548
+ note: `没有匹配该主体的权限行(幂等)。注意 list_permission 不回 unitId,匹配靠解析出的显示名「${identity.name ?? "?"}」/code;不确定时用 workflow.permissions 拿 id 再按 permissionId 删。`,
3549
+ permissions: rows.map((row) => ({ id: row.id ?? null, name: row.name ?? null, unitType: row.unitType ?? null, code: row.code ?? null })),
3550
+ actor,
3267
3551
  },
3268
3552
  };
3553
+ }
3554
+ beforeRow = {
3555
+ id: hit.id ?? null,
3556
+ name: hit.name ?? null,
3557
+ unitType: hit.unitType ?? unitType,
3558
+ fullName: hit.fullName ?? null,
3559
+ code: hit.code ?? null,
3560
+ corpId: hit.corpId ?? null,
3561
+ unitId: args.unitId,
3562
+ };
3269
3563
  permissionId = String(hit.id ?? "");
3270
3564
  }
3565
+ else {
3566
+ // Capture the row being removed so the receipt has a rebuildable before
3567
+ // face even when the caller only passed permissionId (r30d F11).
3568
+ const workflowCode = await resolveWorkflowArg(session, { workflowCode: args.workflowCode, schemaCode: args.schemaCode }).catch(() => null);
3569
+ workflowCodeForActor = workflowCode ?? undefined;
3570
+ if (workflowCode) {
3571
+ const rows = await permissionRows(session, workflowCode);
3572
+ const hit = rows.find((row) => String(row.id ?? "") === permissionId);
3573
+ if (hit)
3574
+ beforeRow = {
3575
+ id: hit.id ?? null,
3576
+ name: hit.name ?? null,
3577
+ unitType: hit.unitType ?? null,
3578
+ fullName: hit.fullName ?? null,
3579
+ code: hit.code ?? null,
3580
+ corpId: hit.corpId ?? null,
3581
+ };
3582
+ }
3583
+ }
3271
3584
  await wireCall(session, { path: ENDPOINTS.workflowDeletePermission, method: "POST", query: { permissionId } });
3272
- return { performed: { errcode: 0 }, readback: { permissionId, removed: true } };
3585
+ const actor = await resolvePermissionActor(session);
3586
+ return {
3587
+ performed: { errcode: 0 },
3588
+ readback: {
3589
+ permissionId,
3590
+ removed: true,
3591
+ ...(workflowCodeForActor ? { workflowCode: workflowCodeForActor } : {}),
3592
+ before: beforeRow,
3593
+ actor,
3594
+ ...(beforeRow
3595
+ ? {}
3596
+ : { rollbackable: false, rollbackNote: "删除前未捕获到权限行详情(只给了 permissionId 且未能解析所属流程);撤错需手工按 id 复建。" }),
3597
+ },
3598
+ };
3599
+ },
3600
+ rollback: {
3601
+ target: (args) => `workflow-permission:${String(args.workflowCode ?? args.schemaCode ?? args.permissionId ?? "")}`,
3602
+ read: async (session, args) => {
3603
+ // Prefer resolving the concrete row so inverse can re-add it.
3604
+ const workflowCode = await resolveWorkflowArg(session, { workflowCode: args.workflowCode, schemaCode: args.schemaCode }).catch(() => null);
3605
+ if (!workflowCode)
3606
+ return null;
3607
+ const rows = await permissionRows(session, workflowCode);
3608
+ if (typeof args.permissionId === "string" && args.permissionId) {
3609
+ return rows.find((row) => String(row.id ?? "") === args.permissionId) ?? null;
3610
+ }
3611
+ if (typeof args.unitId === "string" && args.unitType !== undefined) {
3612
+ const unitType = args.unitType === 1 || args.unitType === "DEPARTMENT" ? 1 : args.unitType === 2 || args.unitType === "ROLE" ? 2 : 3;
3613
+ const identity = await unitIdentity(session, unitType, args.unitId);
3614
+ return rows.find((row) => String(row.unitType ?? "") === String(unitType) && (String(row.id ?? "") === args.unitId ||
3615
+ String(row.code ?? "") === args.unitId ||
3616
+ (identity.name !== null && String(row.name ?? "") === identity.name) ||
3617
+ (identity.code !== null && String(row.code ?? "") === identity.code) ||
3618
+ (identity.name !== null && String(row.fullName ?? "") === identity.name))) ?? null;
3619
+ }
3620
+ return null;
3621
+ },
3622
+ inverse: ({ before, args }) => {
3623
+ if (before == null)
3624
+ return { kind: "irreversible", reason: "removePermission 前未捕获到权限行(before 为空),无法自动复建;用 workflow.addPermission 手工补回" };
3625
+ const row = before;
3626
+ const workflowCode = String(args.workflowCode ?? "");
3627
+ const unitId = typeof args.unitId === "string" && args.unitId ? args.unitId : null;
3628
+ const unitType = args.unitType !== undefined
3629
+ ? (args.unitType === 1 || args.unitType === "DEPARTMENT" ? 1 : args.unitType === 2 || args.unitType === "ROLE" ? 2 : 3)
3630
+ : (row.unitType === 1 || row.unitType === "1" ? 1 : row.unitType === 2 || row.unitType === "2" ? 2 : row.unitType === 3 || row.unitType === "3" ? 3 : null);
3631
+ if (!workflowCode || !unitId || unitType === null)
3632
+ return {
3633
+ kind: "irreversible",
3634
+ reason: `before 有行(name=${String(row.name ?? "?")} unitType=${String(row.unitType ?? "?")}),但缺少可重放的 workflowCode+unitId+unitType;请手工:omc call workflow.addPermission --args '{"workflowCode":"<code>","unitId":"<id>","unitType":${unitType ?? 2}}'`,
3635
+ partial: [],
3636
+ };
3637
+ return {
3638
+ kind: "calls",
3639
+ calls: [{ tool: "workflow.addPermission", args: { workflowCode, unitId, unitType } }],
3640
+ notes: [`复建被删行:name=${String(row.name ?? "?")} unitType=${unitType}`],
3641
+ };
3642
+ },
3273
3643
  },
3274
3644
  });
3275
3645
  tools.push(defineTool({
@@ -73,9 +73,11 @@ export async function cmdModelScaffold(args) {
73
73
  const forcePublish = args.includes("--force-publish");
74
74
  const usage = [
75
75
  "用法:omc model-scaffold --code <模型code> --name <模型名> --fields '<JSON 数组>' [--folder <目录id>] [--apply]",
76
- " --fields 每条是一个 field.create 的参数:{\"code\",\"name\",\"propertyType\",...};子表加 \"childFields\":[...]。",
76
+ " --fields 每条是一个 field.create 的参数:{\"code\",\"name\",\"propertyType\",...};子表加 \"subFields\":[...]。",
77
+ " 子表键名统一用 subFields(field.create 平台键);childFields 仍作别名接受,但 --help 只展示 subFields。",
77
78
  " 默认 dry-run(列出将要做什么);--apply 依次执行 建模型→建字段→建子列→发布字段→发布表单。",
78
79
  " 幂等:模型/字段已存在记为 skipped 继续;有真失败时默认不发布(--force-publish 可强发)。",
80
+ " 每个子调用使用独立 requestId(避免同 id 不同参数被幂等门拒绝);重入已存在字段记 skipped。",
79
81
  " 从文件读:--fields-file <配置根内路径>。",
80
82
  ].join("\n");
81
83
  const invalid = (message) => {
@@ -121,7 +123,7 @@ export async function cmdModelScaffold(args) {
121
123
  for (const field of preflightTargets) {
122
124
  const probe = await runToolCall("field.create", {
123
125
  args: scaffoldFieldArgs(code, field),
124
- requestId: request,
126
+ requestId: `${request}:preflight:${field.code}`,
125
127
  dryRun: true,
126
128
  ...(environment !== undefined ? { environment } : {}),
127
129
  print: () => undefined,
@@ -168,11 +170,17 @@ export async function cmdModelScaffold(args) {
168
170
  // lets that through writes N extra lines to stdout and breaks the
169
171
  // one-envelope contract (the classic trap recorded in HANDOFF §6). Swallow the
170
172
  // per-call lines and surface the useful ones in `steps[]` instead.
173
+ // r31c F9: each sub-call needs its own requestId. Reusing the composer-level
174
+ // id makes the 2nd+ field.create look like "same id, different args" and the
175
+ // idempotency gate refuses them (probe: created:1 failed:6).
176
+ let subCallSeq = 0;
171
177
  const call = async (tool, toolArgs) => {
172
178
  const lines = [];
179
+ subCallSeq += 1;
180
+ const perCallRequestId = `${request}:${tool}:${subCallSeq}`;
173
181
  const outcome = await runToolCall(tool, {
174
182
  args: toolArgs,
175
- requestId: request,
183
+ requestId: perCallRequestId,
176
184
  ...(environment !== undefined ? { environment } : {}),
177
185
  print: (line) => lines.push(line),
178
186
  });