@stalfh233/omc-cli 0.4.92 → 0.4.94

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.
@@ -93,7 +93,7 @@ export async function cmdApprovalFlow(args) {
93
93
  return 0;
94
94
  }
95
95
  // 1) 确保流程存在(一个模型只能有一个:已存在则直接 update)
96
- const createOutcome = await runToolCall("workflow.create", { args: { schemaCode: schema, code: compiled.workflowCode, name: `${schema} 审批流`, ...(environment ? { environment } : {}) }, requestId: request, environment });
96
+ const createOutcome = await runToolCall("workflow.create", { args: { schemaCode: schema, code: compiled.workflowCode, name: `${schema} 审批流`, ...(environment ? { environment } : {}) }, requestId: request, environment, print: () => undefined });
97
97
  if (createOutcome.exitCode !== 0) {
98
98
  const result = createOutcome.result;
99
99
  const blocker = String(result.blocker ?? "");
@@ -102,8 +102,24 @@ export async function cmdApprovalFlow(args) {
102
102
  return createOutcome.exitCode;
103
103
  }
104
104
  }
105
+ // r14 U1: `<schema>_wf` is THIS composer's own naming convention (not the
106
+ // platform's), and `workflow.create` is idempotent — when that code already
107
+ // exists it answers alreadyPresent and writes nothing, so the following
108
+ // update() merges into whatever template was left behind. A residual from an
109
+ // earlier round then silently inherits the new nodes (r14 chased exactly this:
110
+ // a leftover inspHazardFlow made the next run's behaviour inexplicable).
111
+ const createReadback = createOutcome.result?.readback;
112
+ const preexistingTemplate = createReadback?.alreadyPresent === true;
113
+ const preexistingSection = preexistingTemplate
114
+ ? {
115
+ preexistingTemplate: true,
116
+ preexistingWarning: `流程 code ${compiled.workflowCode} 在本轮之前就已存在(workflow.create 幂等跳过,未新建)。` +
117
+ `本次 update 是**合并**进这条既有模板,旧节点/旧连线会保留——它们可能属于上一次运行或另一个模型。` +
118
+ `如果不是有意复用,先 workflow.delete(有实例会 402508,用 model-purge)或换一个 workflowCode 再跑。`,
119
+ }
120
+ : {};
105
121
  // 2) 取字段清单构建审批节点只读权限(r75 修正:审批节点 editable:false)
106
- const fieldList = await runToolCall("field.list", { args: { schemaCode: schema, published: true, ...(environment ? { environment } : {}) }, requestId: request, environment });
122
+ const fieldList = await runToolCall("field.list", { args: { schemaCode: schema, published: true, ...(environment ? { environment } : {}) }, requestId: request, environment, print: () => undefined });
107
123
  const fieldRows = (fieldList.result?.fields ?? fieldList.result?.items ?? []);
108
124
  const propertyCodes = fieldRows.map((row) => String(row.code ?? row.propertyCode ?? "")).filter(Boolean);
109
125
  const approvalNodes = compiled.activities.filter((a) => a.activityType === "PARTICIPANT" && a.participant !== "{Originator}");
@@ -113,16 +129,16 @@ export async function cmdApprovalFlow(args) {
113
129
  // 3) update 模板(merge 语义;工具自动补 sheetCode/链路/设计器默认)
114
130
  const updateOutcome = await runToolCall("workflow.update", {
115
131
  args: { schemaCode: schema, code: compiled.workflowCode, template: { workflowCode: compiled.workflowCode, activities: activitiesWithPermissions }, ...(environment ? { environment } : {}) },
116
- requestId: request, environment,
132
+ requestId: request, environment, print: () => undefined,
117
133
  });
118
134
  if (updateOutcome.exitCode !== 0) {
119
135
  writeEnvelope(envelope(classifyResult(updateOutcome.result).status, updateOutcome.result, request, classifyResult(updateOutcome.result).error));
120
136
  return updateOutcome.exitCode;
121
137
  }
122
138
  // 4) publish
123
- const publishOutcome = await runToolCall("workflow.publish", { args: { schemaCode: schema, code: compiled.workflowCode, ...(environment ? { environment } : {}) }, requestId: request, environment });
139
+ const publishOutcome = await runToolCall("workflow.publish", { args: { schemaCode: schema, code: compiled.workflowCode, ...(environment ? { environment } : {}) }, requestId: request, environment, print: () => undefined });
124
140
  const classified = classifyResult(publishOutcome.result);
125
- writeEnvelope(envelope(classified.status, { schemaCode: schema, workflowCode: compiled.workflowCode, steps: steps.length, publish: publishOutcome.result }, request, classified.error));
141
+ writeEnvelope(envelope(classified.status, { schemaCode: schema, workflowCode: compiled.workflowCode, steps: steps.length, ...preexistingSection, publish: publishOutcome.result }, request, classified.error));
126
142
  if (publishOutcome.exitCode === 0)
127
143
  process.stderr.write("[omc] 前台生效验证清单:1) 造一条数据提交走流程;2) 每级审批人在门户有待办(角色用 FindUserByRole 需审批人挂在发起人同部门,否则 PENDING 不来);3) 审批节点字段只读(不可改单);4) 驳回退发起人核对;5) 设计器打开流程确认节点链显示正常。\n");
128
144
  return publishOutcome.exitCode;
@@ -75,6 +75,36 @@ export function serializeEnvelope(value) {
75
75
  export function requestId() {
76
76
  return randomUUID();
77
77
  }
78
+ /**
79
+ * r16 P2: `rule-graph-validation-failed` always named the same example
80
+ * (`business-rule-branch-conditional`) no matter WHICH lint code fired, so a
81
+ * QUERY-shape failure sent the caller to a BRANCH example (2 wasted calls, 0
82
+ * benefit). Route by the first code's prefix and only fall back to the example.
83
+ */
84
+ export function ruleGraphRecovery(validation) {
85
+ if (!Array.isArray(validation))
86
+ return undefined;
87
+ const codes = validation
88
+ .filter((entry) => entry !== null && typeof entry === "object")
89
+ .filter((entry) => entry.severity !== "warning")
90
+ .map((entry) => String(entry.code ?? ""));
91
+ const first = codes[0];
92
+ if (first === undefined)
93
+ return undefined;
94
+ if (first.startsWith("rule-graph-query-"))
95
+ return "omc describe rule.save --param nodeGraph";
96
+ if (first === "rule-graph-node-output-not-addressable")
97
+ return "omc compute --help";
98
+ if (first.startsWith("rule-graph-assign-"))
99
+ return "omc describe rule.save --param nodeGraph";
100
+ if (first.startsWith("rule-graph-preset-system-node") || first === "rule-graph-system-node")
101
+ return "omc describe rule.save --example standard-business-rule-graph";
102
+ if (first.startsWith("rule-graph-branch") || first.startsWith("rule-graph-condition-"))
103
+ return "omc describe rule.save --example business-rule-branch-conditional";
104
+ if (first.startsWith("rule-graph-create") || first.startsWith("rule-graph-query"))
105
+ return "omc describe rule.save --example rule-cross-model-create";
106
+ return undefined;
107
+ }
78
108
  /**
79
109
  * Derive a runnable next command from a refusal payload. Tools that already
80
110
  * carry a structured recovery (an explicit `nextCommand`, an artifact `fetch`
@@ -94,6 +124,11 @@ export function nextCommandFor(result) {
94
124
  const index = trimmed.indexOf("omc ");
95
125
  return index > 0 ? trimmed.slice(index).trim() : undefined;
96
126
  };
127
+ // A tool that already diagnosed the shape wins over its generic recovery: the
128
+ // validation codes say WHAT is wrong, so they pick the right example.
129
+ const routed = ruleGraphRecovery(result.validation) ?? ruleGraphRecovery(result.nodeChecks);
130
+ if (routed !== undefined)
131
+ return routed;
97
132
  const explicit = result.nextCommand;
98
133
  if (typeof explicit === "string" && explicit.trim().length > 0)
99
134
  return explicit.trim();
package/dist/cli.js CHANGED
@@ -419,7 +419,9 @@ async function cmdCall(store, args) {
419
419
  // `--jq` is the same path projection plus `[]` fan-out; a bare `--jq` is the
420
420
  // key inventory (the same muscle memory as `artifact get <id> --jq`).
421
421
  const extractPaths = [...argvValues(args, "--extract"), ...argvValues(args, "--jq")];
422
- const extractKeys = args.includes("--extract-keys") || args.some((token, index) => token === "--jq" && (args[index + 1] === undefined || args[index + 1].startsWith("--")));
422
+ // `artifact get` uses `--keys`; `call` only had `--extract-keys`, so the muscle
423
+ // memory failed with "unknown option '--keys'" (r16 P7). Accept both.
424
+ const extractKeys = args.includes("--extract-keys") || args.includes("--keys") || args.some((token, index) => token === "--jq" && (args[index + 1] === undefined || args[index + 1].startsWith("--")));
423
425
  const dryRun = args.includes("--dry-run");
424
426
  const waitValue = flag("--wait");
425
427
  let waitMs = 0;
@@ -1086,7 +1088,7 @@ function cmdHelp(topic, commandNames, transport, section, sections = false, all
1086
1088
  for (const requested of requestedSections) {
1087
1089
  const sliced = readSkillSection(topic, requested);
1088
1090
  if (sliced?.section)
1089
- found.push({ section: requested, content: sliced.section });
1091
+ found.push({ section: requested, content: sliced.section, chars: sliced.section.length, bytes: Buffer.byteLength(sliced.section, "utf8") });
1090
1092
  else
1091
1093
  missing.push(requested);
1092
1094
  }
@@ -1113,7 +1115,18 @@ function cmdHelp(topic, commandNames, transport, section, sections = false, all
1113
1115
  }
1114
1116
  if (!sliced.section)
1115
1117
  return emitInvalid(transport, { message: `topic ${topic} 没有 section "${singleSection}";可用:${sliced.headings.join("、")}`, blocker: "help-section-not-found" });
1116
- return emitTransportResult({ status: "ok", topic: sliced.document.name, section: singleSection, content: sliced.section }, transport);
1118
+ // r16 cost finding: the same section was re-read SIX times (46 KB) because the
1119
+ // receipt only gave `bytes` while a character count was compared against it.
1120
+ // Emit both, plus `complete:true` so "is this truncated?" never needs a retry.
1121
+ return emitTransportResult({
1122
+ status: "ok",
1123
+ topic: sliced.document.name,
1124
+ section: singleSection,
1125
+ chars: sliced.section.length,
1126
+ bytes: Buffer.byteLength(sliced.section, "utf8"),
1127
+ complete: true,
1128
+ content: sliced.section,
1129
+ }, transport);
1117
1130
  }
1118
1131
  const document = readSkill(topic);
1119
1132
  if (document === undefined) {
@@ -1282,8 +1295,9 @@ async function main() {
1282
1295
  commandExitCode = CLI_EXIT_CODES.ok;
1283
1296
  });
1284
1297
  command.command("doctor").option("--environment <environment>").option("--env <environment>").option("--service <serviceCode>", "可选:体检单个业务集成服务的鉴权参数形状(掩码/占位/全 0 检出,WARN 非致命)").action(async (options) => { commandExitCode = await cmdDoctor(new ConfigStore(defaultPaths()), options.environment ?? options.env, options.service); });
1285
- command.command("call <tool>").option("--env <environment>").option("--environment <environment>").option("--args <json>").option("--args-file <path>").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象);逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
1286
- const rest = [tool, ...(options.env ? ["--env", options.env] : []), ...(options.environment ? ["--environment", options.environment] : []), ...(options.args !== undefined ? ["--args", options.args] : []), ...(options.argsFile !== undefined ? ["--args-file", options.argsFile] : []), ...(options.argsList !== undefined ? ["--args-list", options.argsList] : []), ...(options.preflight ? ["--preflight"] : []), ...(Array.isArray(options.extract) ? options.extract.flatMap((path) => ["--extract", path]) : []), ...(Array.isArray(options.jq) ? options.jq.flatMap((path) => path === true ? ["--jq"] : ["--jq", path]) : []), ...(options.extractKeys ? ["--extract-keys"] : []), ...(options.maxOutputBytes !== undefined ? ["--max-output-bytes", options.maxOutputBytes] : []), ...(options.requestId ? ["--request-id", options.requestId] : []), ...(options.timeoutMs !== undefined ? ["--timeout-ms", options.timeoutMs] : []), ...(options.wait !== undefined ? ["--wait", options.wait] : []), ...(options.dryRun ? ["--dry-run"] : [])];
1298
+ command.command("call <tool>").option("--env <environment>").option("--environment <environment>").option("--args <json>").option("--args-file <path>").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象);逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").option("--keys", "与 artifact get 的 --keys 同义(= --extract-keys):列出本次结果 artifact 的顶层键与体积").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
1299
+ const rest = [tool, ...(options.env ? ["--env", options.env] : []), ...(options.environment ? ["--environment", options.environment] : []), ...(options.args !== undefined ? ["--args", options.args] : []), ...(options.argsFile !== undefined ? ["--args-file", options.argsFile] : []), ...(options.argsList !== undefined ? ["--args-list", options.argsList] : []), ...(options.preflight ? ["--preflight"] : []), ...(Array.isArray(options.extract) ? options.extract.flatMap((path) => ["--extract", path]) : []), ...(Array.isArray(options.jq) ? options.jq.flatMap((path) => path === true ? ["--jq"] : ["--jq", path]) : []), ...(options.extractKeys ? ["--extract-keys"] : []),
1300
+ ...(options.keys ? ["--keys"] : []), ...(options.maxOutputBytes !== undefined ? ["--max-output-bytes", options.maxOutputBytes] : []), ...(options.requestId ? ["--request-id", options.requestId] : []), ...(options.timeoutMs !== undefined ? ["--timeout-ms", options.timeoutMs] : []), ...(options.wait !== undefined ? ["--wait", options.wait] : []), ...(options.dryRun ? ["--dry-run"] : [])];
1287
1301
  commandExitCode = await cmdCall(new ConfigStore(defaultPaths()), rest);
1288
1302
  });
1289
1303
  command.command("compute").option("--schema <schemaCode>").option("--schema-code <schemaCode>").option("--target <propertyCode>").option("--property <propertyCode>").option("--formula <formula>").option("--where <condition>", "条件聚合:\"<子列code> = <值>\",与 --formula \"{<子表code>.<子列code>}\" 配合编译为 COUNTIF/SUMIF").option("--aggregate <count|sum>", "条件聚合用哪种:count(默认,→COUNTIF)/ sum(→SUMIF)").option("--allow-unfiltered", "显式接受「对整个子表列无条件聚合」;不给则拒绝(会把不符合条件的行也算进去)").option("--name <name>").option("--env <environment>").option("--environment <environment>").option("--apply").addHelpText("after", "\n字段计算规则拼装器:公式 {字段} 引用 → datarule type2 完整形状(editorText/editorMark 自动生成)。默认 dry-run,--apply 写入。条件聚合用 --formula \"{子表.子列}\" --where \"子列 = 值\"。复杂场景(条件分支/跨模型)走 omc next 的 recipe 路径。\n").action(async (options) => {
@@ -1335,7 +1349,17 @@ async function main() {
1335
1349
  .option("--no-form", "跳过表单发布")
1336
1350
  .option("--force-publish", "有字段失败也发布(默认停在发布前)")
1337
1351
  .option("--apply")
1338
- .addHelpText("after", "\n一条命令建模型:模型 → N 字段(含子表挂载与子列)→ 发布字段 → 发布表单。默认 dry-run 只列计划。\n")
1352
+ .addHelpText("after", `\n一条命令建模型:模型 → N 字段(含子表挂载与子列)→ 发布字段 → 发布表单。默认 dry-run 只列计划。\n\n` +
1353
+ `字段族名(propertyType,必须逐字匹配;--apply 才校验,dry-run 不校验):\n` +
1354
+ ` SHORT_TEXT LONG_TEXT RICH_TEXT NUMERICAL DATE TIME LOGICAL\n` +
1355
+ ` RADIO CHECKBOX DROPDOWN_BOX DROPDOWN_MULTI_BOX\n` +
1356
+ ` STAFF_SELECTOR STAFF_MULTI_SELECTOR DEPARTMENT_SELECTOR DEPARTMENT_MULTI_SELECTOR\n` +
1357
+ ` WORK_SHEET MULT_WORK_SHEET RELEVANCE_DATA HYPER_LINK ATTACHMENT\n` +
1358
+ ` CHILD_TABLE FORMULA ADDRESS\n` +
1359
+ ` 常见错写:DEPARTMENT_MULTI→DEPARTMENT_MULTI_SELECTOR、STAFF_SINGLE→STAFF_SELECTOR、DROP_DOWN_BOX→DROPDOWN_BOX\n\n` +
1360
+ `choices(RADIO/CHECKBOX/DROPDOWN 系列):**字符串数组**,如 [\"待处理\",\"已完成\"]——不是 [{key,value}]\n` +
1361
+ `关联字段(WORK_SHEET/MULT_WORK_SHEET):options:{\"schemaCode\":\"<目标模型>\"} + relativeCode:\"<目标模型>\"\n` +
1362
+ `子表:childFields:[…](每条同字段形状)\n`)
1339
1363
  .action(async (options) => {
1340
1364
  const rest = ["model-scaffold",
1341
1365
  ...(options.code ? ["--code", options.code] : []),
@@ -209,8 +209,12 @@ export function registerCustomPageTools(tools) {
209
209
  code: args.code,
210
210
  existDraft: body.existDraft ?? null,
211
211
  published: body.published ?? null,
212
- ...(part !== "published" ? { draftContent: draft, draftBytes: draft === null ? 0 : Buffer.byteLength(draft, "utf8") } : {}),
213
- ...(part !== "draft" ? { publishedContent: published, publishedBytes: published === null ? 0 : Buffer.byteLength(published, "utf8") } : {}),
212
+ ...(part !== "published" ? { draftContent: draft, draftChars: draft === null ? 0 : draft.length, draftBytes: draft === null ? 0 : Buffer.byteLength(draft, "utf8") } : {}),
213
+ ...(part !== "draft" ? { publishedContent: published, publishedChars: published === null ? 0 : published.length, publishedBytes: published === null ? 0 : Buffer.byteLength(published, "utf8") } : {}),
214
+ // r16 P8: `--jq content` returned null because the key is publishedContent.
215
+ // Keep the canonical keys and point at them instead of letting the miss
216
+ // read as "the body is empty".
217
+ contentKeyHint: part === "draft" ? "正文键是 draftContent(不是 content)" : part === "published" ? "正文键是 publishedContent(不是 content)" : "正文键是 draftContent / publishedContent(不是 content)",
214
218
  };
215
219
  }),
216
220
  }));
@@ -1163,7 +1163,7 @@ export function validateConditionLeaf(leaf, nodeCode, where, warnings, legacyTol
1163
1163
  else if (String(leaf.ruleConditionType) === "CHANGED")
1164
1164
  warnings?.push({ code: "rule-graph-legacy-operator-roundtrip", nodeCode, key: "ruleConditionType", severity: "warning", message: `${where}: ruleConditionType "CHANGED" 是设计器 UI 独有变更检测算子——按原样保留回传(round-trip 保真),OMC 不支持新建此类算子;新增条件请用 ${RULE_CONDITION_TYPES.join("/")}` });
1165
1165
  else
1166
- blockers.push({ code: "rule-graph-condition-type", nodeCode, key: "ruleConditionType", message: `${where}: ruleConditionType "${String(leaf.ruleConditionType)}}" 不在 ${RULE_CONDITION_TYPES.join("/")}` });
1166
+ blockers.push({ code: "rule-graph-condition-type", nodeCode, key: "ruleConditionType", message: `${where}: ruleConditionType "${String(leaf.ruleConditionType)}" 不在 ${RULE_CONDITION_TYPES.join("/")}` });
1167
1167
  }
1168
1168
  if (leaf[BRANCH_EXIT_OPERATOR_KEY] !== undefined && !RULE_CONDITION_TYPES.includes(String(leaf[BRANCH_EXIT_OPERATOR_KEY]))) {
1169
1169
  if (legacyTolerant)
@@ -1555,6 +1555,54 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
1555
1555
  message: `分支节点后接多个跨模型 UPDATE(${fanOut.join(";")})——真机 2026-10-20 该组合在写/运行时报 50000。改成:① 单个跨模型 UPDATE + 条件动作(dataActions 内按条件区分),或 ② 通过 BIZ_ACTION 自调二开接口一次处理多行。`,
1556
1556
  });
1557
1557
  }
1558
+ // r16 P1 (costliest finding of the round: 21 rule.save calls, 14 refusals):
1559
+ // a caller who wants "count the child rows that match X and write the number to
1560
+ // a main field" naturally invents a QUERY node output like `Q1_count` and
1561
+ // references it downstream. The platform has NO such addressable output —
1562
+ // RuleVirtualPropertyType only exposes `var_judgment_status/_total/
1563
+ // _match_count/_non_match_count`, which belong to a BRANCH's own judgment, not
1564
+ // to a QUERY result set. The save passes and the PUBLISH self-check then fails
1565
+ // with「字段不存在」naming the invented code, which sends the caller looking for
1566
+ // a missing field. Refuse before the wire and name the two real paths.
1567
+ const nodeCodes = new Set(nodeRecords.map((node) => String(node.nodeCode ?? "")).filter((code) => code.length > 0));
1568
+ const inventedOutputs = [];
1569
+ const referenceKeys = ["currentSchemaDataItem", "currentDataItemValue", "targetSchemaDataItem", "value"];
1570
+ for (const node of nodeRecords) {
1571
+ const nodeCode = String(node.nodeCode ?? "");
1572
+ const walk = (value, where) => {
1573
+ if (Array.isArray(value)) {
1574
+ value.forEach((entry, index) => walk(entry, `${where}[${index}]`));
1575
+ return;
1576
+ }
1577
+ if (value === null || typeof value !== "object")
1578
+ return;
1579
+ for (const [key, entry] of Object.entries(value)) {
1580
+ if (referenceKeys.includes(key) && typeof entry === "string") {
1581
+ // `Q1_count` / `SearchData_1_rows` where Q1/SearchData_1 IS a node code.
1582
+ const match = /^([A-Za-z][\w]*?)_(count|rows|total|result|output|size|length)$/.exec(entry.trim());
1583
+ if (match && nodeCodes.has(match[1]) && match[1] !== nodeCode)
1584
+ inventedOutputs.push({ nodeCode, reference: entry.trim(), where: `${where}.${key}` });
1585
+ }
1586
+ walk(entry, `${where}.${key}`);
1587
+ }
1588
+ };
1589
+ walk(node, nodeCode || "node");
1590
+ }
1591
+ if (inventedOutputs.length > 0) {
1592
+ const first = inventedOutputs[0];
1593
+ blockers.push({
1594
+ code: "rule-graph-node-output-not-addressable",
1595
+ nodeCode: first.nodeCode,
1596
+ key: first.where,
1597
+ message: `引用 ${inventedOutputs.map((entry) => `${entry.reference}(${entry.where})`).join("、")} 试图当"某个节点的输出变量"——平台**没有**这种可寻址输出。` +
1598
+ `QUERY 节点的结果集只在它自己的下游节点里以 inputParam:"_<节点code>_output" 被消费,不能被条件/赋值按名字取值;` +
1599
+ `平台仅有的虚拟属性是 BRANCH 自身的判断统计(var_judgment_status / var_judgment_total / var_judgment_match_count / var_judgment_non_match_count),` +
1600
+ `那是"分支判定的命中数",不是 QUERY 结果行的计数(RuleVirtualPropertyType.java,8.6.26)。` +
1601
+ `要「统计子表符合条件的行数写入主表字段」,用字段计算规则:` +
1602
+ `omc compute --schema <模型> --target <目标字段> --formula '{<子表schemaCode>.<子列code>}' --where '<子列code> = <值>' --aggregate count(编译成 COUNTIF,服务端在 submit/update 时算)。` +
1603
+ `要「按查询结果逐条新增」才用 QUERY + CREATE(N→N 扇出,recipe: rule-cross-model-create)。`,
1604
+ });
1605
+ }
1558
1606
  if (schemaCode !== undefined && schemaCode.length > 0) {
1559
1607
  const writeTypes = new Set(["UPDATE", "CREATE", "DELETE"]);
1560
1608
  const crossModel = nodeRecords.filter((node) => {
@@ -1843,7 +1891,14 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
1843
1891
  code: "rule-graph-query-conditions-empty",
1844
1892
  nodeCode,
1845
1893
  key: "targetConditionGroups",
1846
- message: `QUERY 的 ${mainEmpty ? "mainConditions" : "sheetConditions"} 没有任何条件叶——引擎 getPage 会直接返回空列表且不写日志(QueryActivityBehavior:78-86,1.1.25),运行期表现为「节点执行开始」后静默无输出。要查全表请显式写一条恒真条件叶(如 targetSchemaDataItem:"id" + ruleConditionType:"NOT_EMPTY"),否则补上真正的筛选条件。`,
1894
+ message: `QUERY 的 ${mainEmpty ? "mainConditions" : "sheetConditions"} 没有任何条件叶——引擎 getPage 会直接返回空列表且不写日志(QueryActivityBehavior:78-86,1.1.25),运行期表现为「节点执行开始」后静默无输出。要查全表请显式写一条恒真条件叶(如 targetSchemaDataItem:"id" + ruleConditionType:"NEP";非空枚举是 EP/NEP,没有 NOT_EMPTY),否则补上真正的筛选条件。` +
1895
+ // r16 P2: the four QUERY requirements used to arrive one refusal at a
1896
+ // time (targetSchemaCode → condition leaf → group shape → 550018).
1897
+ // List them together so one edit satisfies all of them.
1898
+ `\nQUERY 节点的完整要求(一次核对,别一条条试):① targetSchemaCode 必填(子表写 <schemaCode>.<子表code>);` +
1899
+ `② targetConditionGroups 必须是对象 {mainConditions:[[…]],sheetConditions:[[…]]},不能是裸数组;` +
1900
+ `③ 主表条件与子表条件都要至少一条叶(查全表就写 id + NEP 恒真叶);` +
1901
+ `④ 结果集不能被下游按名字引用——下游消费写 inputParam:"_${String(node.nodeCode ?? "")}_output",要汇总计数改用 omc compute。`,
1847
1902
  });
1848
1903
  }
1849
1904
  if (node.dataCondition !== undefined && node.dataCondition !== null && countConditionLeaves(node.dataCondition) === 0) {
@@ -288,6 +288,66 @@ async function loadExistingBizObject(session, schemaCode, sheetCode, objectId) {
288
288
  throw error;
289
289
  }
290
290
  }
291
+ /**
292
+ * Is this work item still the caller's open todo? `false` = it was already
293
+ * submitted (the usual meaning of 100005, since the platform rate-limits per
294
+ * work item). `null` = could not tell.
295
+ */
296
+ async function workItemStillOpen(session, workflowInstanceId, workItemId) {
297
+ if (typeof workflowInstanceId !== "string" || workflowInstanceId.length === 0)
298
+ return null;
299
+ try {
300
+ const data = (await wireCall(session, {
301
+ path: ENDPOINTS.workflowListWorkItems,
302
+ query: { workflowInstanceId, status: "PENDING", page: "0", size: "200" },
303
+ })).data;
304
+ const rows = Array.isArray(data?.content) ? data.content : wireRows(data);
305
+ if (typeof workItemId !== "string" || workItemId.length === 0)
306
+ return rows.length > 0;
307
+ return rows.some((row) => String(row.workItemId ?? row.id ?? "") === workItemId);
308
+ }
309
+ catch {
310
+ return null;
311
+ }
312
+ }
313
+ /**
314
+ * People-picker values ride the wire as a bare user id (or an array of ids), but
315
+ * they COME BACK as objects. Writing the readback value verbatim trips
316
+ * `UserBizField.fillClientContent` in the engine (Assertion Failed → bare 50000,
317
+ * r16 P5). Detect the object/array shape on the way IN, against the published
318
+ * field types, so the refusal names the field instead of letting the platform
319
+ * answer 服务异常.
320
+ */
321
+ async function personFieldShapeViolations(session, schemaCode, formData) {
322
+ const STAFF_TYPES = new Set([50, 51, 52]);
323
+ let rows;
324
+ try {
325
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
326
+ }
327
+ catch {
328
+ return [];
329
+ }
330
+ const staffCodes = new Map();
331
+ for (const row of rows) {
332
+ const type = Number(row.type ?? row.propertyType);
333
+ const code = String(row.code ?? row.propertyCode ?? "");
334
+ if (code && STAFF_TYPES.has(type))
335
+ staffCodes.set(code, String(row.typeName ?? type));
336
+ }
337
+ const violations = [];
338
+ for (const [code, value] of Object.entries(formData)) {
339
+ if (!staffCodes.has(code))
340
+ continue;
341
+ if (value === null || value === undefined || value === "")
342
+ continue;
343
+ if (typeof value === "string")
344
+ continue;
345
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
346
+ continue;
347
+ violations.push({ code, got: Array.isArray(value) ? `array[${value.length}]` : typeof value });
348
+ }
349
+ return violations;
350
+ }
291
351
  /**
292
352
  * Portal-required fields the published form declares (`control.options.required`
293
353
  * or `requiredFormula`). The portal enforces these client-side; the REST
@@ -1032,6 +1092,22 @@ export function registerRuntimeDataLane(tools) {
1032
1092
  // so a missing required value must be REPORTED, not silently accepted as a
1033
1093
  // portal-valid submit (dogfood R3). Best-effort read; a failure never
1034
1094
  // blocks the submit.
1095
+ // r16 P5:人员字段读回来是对象数组 `[{id,type,name,…}]`,写回去必须是**字符串 id**
1096
+ // ——写成对象会让 UserBizField.fillClientContent 断言失败,平台只回 50000。
1097
+ // 读写不对称且 schema 无法表达(formData 是 additionalProperties),所以在
1098
+ // 写前按已发布字段类型做一次浅校验,把这类形状错误变成一条指名道姓的拒绝。
1099
+ const staffShaped = await personFieldShapeViolations(session, args.schemaCode, formData).catch(() => []);
1100
+ if (staffShaped.length > 0)
1101
+ throw new ToolRefusalError({
1102
+ status: "refused",
1103
+ blocker: "submit-person-field-shape",
1104
+ schemaCode: args.schemaCode,
1105
+ fields: staffShaped,
1106
+ message: `这些人员字段的取值是对象/数组:${staffShaped.map((entry) => `${entry.code}(${entry.got})`).join("、")}。` +
1107
+ `人员字段**写入只接受字符串 id**(多个用逗号或数组的字符串 id)——读回来是 \`[{id,type,name,fullDepartment,…}]\`,` +
1108
+ `把读回值原样写回会让平台 UserBizField.fillClientContent 断言失败、只回 50000 服务异常。` +
1109
+ `写成 "\"<用户id>\"" 或 ["<id1>","<id2>"]。`,
1110
+ });
1035
1111
  const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData).catch(() => ({ requiredFields: [], missingRequired: [] }));
1036
1112
  // Directive: a workflow-backed model's submit must START the workflow, not
1037
1113
  // merely save a sheet row. When the caller names no workflow params, detect
@@ -1039,6 +1115,38 @@ export function registerRuntimeDataLane(tools) {
1039
1115
  // with no workflow stays a direct sheet submit (formType=2 -> COMPLETED).
1040
1116
  let explicitWorkflowCode = typeof args.workflowCode === "string" && args.workflowCode.trim().length > 0 ? args.workflowCode.trim() : "";
1041
1117
  let autoStarted = false;
1118
+ // r16 P4: a second submit on the same work item answers 100005
1119
+ // SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 — the SAME numeric code
1120
+ // as TEST_CONNECTION_FAIL, so the text is the only discriminator). The
1121
+ // platform rate-limits per work item, not per request body, so retrying
1122
+ // with a different payload changes nothing; the caller needs to know
1123
+ // whether the FIRST submit actually landed. Diagnose before the wire.
1124
+ if (hasWorkItem) {
1125
+ try {
1126
+ const activity = (await wireCall(session, {
1127
+ path: ENDPOINTS.workflowListInstanceActivity,
1128
+ query: { workflowInstanceId: String(args.workflowInstanceId ?? "") },
1129
+ })).data;
1130
+ const rows = Array.isArray(activity?.activities) ? activity.activities : wireRows(activity);
1131
+ const mine = rows.filter((row) => String(row.workItemId ?? "") === args.workItemId);
1132
+ const finished = mine.length > 0 && mine.every((row) => /FINISHED|COMPLETED/i.test(String(row.state ?? row.status ?? "")));
1133
+ if (finished)
1134
+ throw new ToolRefusalError({
1135
+ status: "refused",
1136
+ blocker: "submit-workitem-already-finished",
1137
+ schemaCode: args.schemaCode,
1138
+ workItemId: args.workItemId,
1139
+ nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowInstanceId: String(args.workflowInstanceId ?? "") })}'`,
1140
+ message: `待办 ${String(args.workItemId)} 已经是完成状态——本次提交**没有发生**,平台会回 100005「频繁重复提交!」(SYS_REPLAY_ERROR,按 workItem 维度限流,换载荷/等待都无效)。` +
1141
+ `这不是失败,是"已经提交过了"。用 workitem.list 看下一级待办,或 data.load 看当前 sequenceStatus,不要重复提交。`,
1142
+ });
1143
+ }
1144
+ catch (error) {
1145
+ if (error instanceof ToolRefusalError)
1146
+ throw error;
1147
+ // A read failure must not block the submit (fail open).
1148
+ }
1149
+ }
1042
1150
  if (!explicitWorkflowCode && !hasWorkItem && !hasInstance) {
1043
1151
  const resolved = await resolvePublishedWorkflowCode(session, args.schemaCode);
1044
1152
  if (resolved) {
@@ -1150,11 +1258,39 @@ export function registerRuntimeDataLane(tools) {
1150
1258
  // platform answer "权限不足", which reads like a misconfiguration.
1151
1259
  if (hasWorkItem)
1152
1260
  await diagnoseApprovalSubmitIdentity(session, { workflowInstanceId: args.workflowInstanceId, workItemId: args.workItemId });
1153
- const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
1154
- path: ENDPOINTS.runtimeFormSubmit,
1155
- method: "POST",
1156
- body: payload,
1157
- }), undefined, hasWorkflowCode ? explicitWorkflowCode : undefined);
1261
+ let performed;
1262
+ try {
1263
+ performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
1264
+ path: ENDPOINTS.runtimeFormSubmit,
1265
+ method: "POST",
1266
+ body: payload,
1267
+ }), undefined, hasWorkflowCode ? explicitWorkflowCode : undefined);
1268
+ }
1269
+ catch (error) {
1270
+ // r16 P4: 100005 is SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 —
1271
+ // the same numeric code as TEST_CONNECTION_FAIL, so only the text
1272
+ // separates them). The platform rate-limits per WORK ITEM, so a different
1273
+ // payload or a longer wait changes nothing. The actionable question is
1274
+ // "did the earlier submit land?", which the todo list answers.
1275
+ if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.replayTokenErrcode) {
1276
+ const stillMine = await workItemStillOpen(session, args.workflowInstanceId, args.workItemId).catch(() => null);
1277
+ throw new ToolRefusalError({
1278
+ status: "refused",
1279
+ blocker: stillMine === false ? "submit-workitem-already-finished" : "submit-rate-limited",
1280
+ schemaCode: args.schemaCode,
1281
+ workItemId: args.workItemId ?? null,
1282
+ workflowInstanceId: args.workflowInstanceId ?? null,
1283
+ nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowInstanceId: String(args.workflowInstanceId ?? "") })}'`,
1284
+ message: stillMine === false
1285
+ ? `100005「频繁重复提交!」在这里的真实含义:待办 ${String(args.workItemId ?? "(未提供)")} **已经提交过了**。限流按 workItem 维度,换载荷、等待、换 requestId 都无效——这不是失败。用 workitem.list 看下一级待办,或 data.load 看 sequenceStatus,不要重试本次提交。`
1286
+ : `100005 = 平台 SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68,与"测试连接不通过"共用该数值码,只能靠文案区分)。` +
1287
+ `两种成因:① 该待办已经提交过;② startWorkflow 路径的 replayToken 未刷新。` +
1288
+ `${stillMine === null ? "(待办状态读取失败,无法判定)" : stillMine ? "该待办仍在——多半是限流窗口未过。" : ""}` +
1289
+ `先 workitem.list 核对当前待办,确认没提交成功再等一会儿重试;改 payload 不会绕过它。`,
1290
+ });
1291
+ }
1292
+ throw error;
1293
+ }
1158
1294
  const performedData = (performed.data ?? {});
1159
1295
  let readback = { formType };
1160
1296
  if (formType === "2") {
@@ -165,7 +165,11 @@ export async function planPurge(options, injected) {
165
165
  businessRules,
166
166
  ...(workflowCode !== undefined ? { workflowCode } : {}),
167
167
  instanceCount,
168
+ // Renamed intent (r16 P9): this counts WORK ITEMS in the workflow (one per
169
+ // open activity, including ones that are not the caller's todo), not "my
170
+ // todos". r16 saw 2 here while workitem.list showed 0 and read it as a bug.
168
171
  pendingWorkItems,
172
+ pendingWorkItemsNote: "这是该流程的在办工作项总数(每个未完结活动一条,不等于「我的待办」);看自己的待办用 workitem.list",
169
173
  instanceIds: [...instanceIds].slice(0, 50),
170
174
  steps,
171
175
  blockers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.92",
3
+ "version": "0.4.94",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,9 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@stalfh233/omc-fake-platform": "0.4.92",
53
+ "@stalfh233/omc-fake-platform": "0.4.94",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.92",
55
+ "omc-core": "0.4.94",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",