@stalfh233/omc-cli 0.6.1 → 0.6.3

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.
@@ -107,7 +107,7 @@ const SURFACES = [
107
107
  operations: ["list", "get", "save"],
108
108
  requires: ["field"],
109
109
  intent: { level: "partial", note: "声明式支持数据事件规则与定时任务:紧凑 actions 或 nodeGraph 透传(BRANCH/BIZ_ACTION/定时图用透传);写 route.save + rule.enable/disable。" },
110
- notes: "数据事件只分发到平台预置 code 行(Create/Update/Delete);自定义 ruleCode 需按钮/流程/定时/显式触发。**「每天定时执行」= 定时任务 TIMING_TASK(平台原生,非外部定时器、非在线 JS)**:`rule.save` 传含 SCHEDULER 节点 + `schedulerSetting`(`bizRuleType:2`)的 nodeGraph,见 `omc describe rule.save --example rule-timing-task`。保存≠可见,需 rule.enable;绑定 BizMethod 前先有已发布业务集成方法。",
110
+ notes: "数据事件只分发到平台预置 code 行(Create/Update/Delete);自定义 ruleCode 需按钮/流程/定时/显式触发。**「每天定时执行」= 定时任务 TIMING_TASK(平台原生,非外部定时器、非在线 JS)**:`rule.save` 传含 SCHEDULER 节点 + `schedulerSetting`(`bizRuleType:2`)的 nodeGraph。**定时改数据**(GET_LIST + UPDATE 动作节点)见 `--example rule-timing-task`;**定时调业务集成方法**(SCHEDULER-only + `dataSourceType:BIZ_SERVICE` + `methodMapping` 含应用编码 methodCode)见 `--example rule-timing-bizservice`。保存≠可见,需 rule.enable;绑定 BizMethod 前先有已发布业务集成方法。",
111
111
  },
112
112
  {
113
113
  id: "rule-lifecycle",
@@ -110,6 +110,11 @@ function withRepeatWarning(value) {
110
110
  const status = String(envelope.status ?? "");
111
111
  if (status !== "refused" && status !== "error" && status !== "uncertain")
112
112
  return value;
113
+ // A5(GitLab #15):auth-config(凭据缺失/配置问题)不是参数形状问题——
114
+ // 「不要微调参数重试」的提示会把排查方向带偏,实测连续 6 次失败全在改形状。
115
+ const errorRecord = envelope.error && typeof envelope.error === "object" ? envelope.error : undefined;
116
+ if (typeof errorRecord?.code === "string" && errorRecord.code === "auth-config")
117
+ return value;
113
118
  const result = envelope.result && typeof envelope.result === "object" ? envelope.result : undefined;
114
119
  const blocker = typeof result?.blocker === "string" ? result.blocker : typeof envelope.blocker === "string" ? envelope.blocker : undefined;
115
120
  const prior = priorRepeatedFailures(currentUsageCommand(), blocker);
package/dist/cli.js CHANGED
@@ -232,6 +232,18 @@ async function cmdDoctor(store, environment, serviceCode) {
232
232
  steps.push(`许可证 FAIL 许可证面探测失败:${redactText(error.message, [])}`);
233
233
  }
234
234
  }
235
+ // A5(GitLab #15):doctor 此前只体检当前环境,其他环境「列得出、没凭据」
236
+ // 完全不可见——补一行全环境凭据矩阵(存在性探测,不触碰值)。
237
+ try {
238
+ const envRows = store.listEnvironments().map((item) => ({ name: item.name, credential: store.hasCredential(item.name) }));
239
+ const missingEnvs = envRows.filter((row) => !row.credential);
240
+ steps.push(missingEnvs.length === 0
241
+ ? `环境凭据 ok ${envRows.map((row) => row.name).join("、")} 全部在位`
242
+ : `环境凭据 WARN ${missingEnvs.map((row) => row.name).join("、")} 无凭据——调用会回 auth-config(不是参数问题);omc env list 可核对,omc setup 补`);
243
+ }
244
+ catch {
245
+ // 环境清单读不到不阻断体检主链路
246
+ }
235
247
  // T09 — optional per-service credential shape check (--service <code>).
236
248
  // Non-fatal: a masked/placeholder credential does not make the ENVIRONMENT
237
249
  // unhealthy; it explains why that service's calls would 401/500. Findings
@@ -450,7 +462,12 @@ async function cmdCall(store, args) {
450
462
  // every list read that only wanted one column paid a second artifact round.
451
463
  // `--jq` is the same path projection plus `[]` fan-out; a bare `--jq` is the
452
464
  // key inventory (the same muscle memory as `artifact get <id> --jq`).
453
- const extractPaths = [...argvValues(args, "--extract"), ...argvValues(args, "--jq")];
465
+ const extractPaths = [...argvValues(args, "--extract"), ...argvValues(args, "--jq")]
466
+ // A3(GitLab #15):逗号多路径——一张 9 列表此前要 9 次 artifact get。
467
+ // 路径语法(点式/下标/切片/[]/按键)不含逗号,拆分无歧义。
468
+ .flatMap((path) => path.split(","))
469
+ .map((path) => path.trim())
470
+ .filter(Boolean);
454
471
  // `artifact get` uses `--keys`; `call` only had `--extract-keys`, so the muscle
455
472
  // memory failed with "unknown option '--keys'" (r16 P7). Accept both.
456
473
  const extractKeys = args.includes("--extract-keys") || args.includes("--keys") || args.some((token, index) => token === "--jq" && (args[index + 1] === undefined || args[index + 1].startsWith("--")));
@@ -520,7 +537,7 @@ async function cmdCall(store, args) {
520
537
  // Keep the REJECTED path on stderr only: the machine envelope must not leak
521
538
  // host layout (ticket 103), so it carries the root plus the bare file name.
522
539
  const rejectedName = typeof argsFile === "string" ? basename(resolve(configRoot, argsFile)) : "";
523
- writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: outOfRoot ? `--args-file 必须位于配置根内(配置根 ${configRoot}${rejectedName ? `;本次文件 ${rejectedName}` : ""})` : detail, retryable: false, ...(outOfRoot ? { configurationRoot: configRoot } : {}) }));
540
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: outOfRoot ? `--args-file 必须位于配置根内(配置根 ${configRoot}${rejectedName ? `;本次文件 ${rejectedName}` : ""})。免落盘替代:--args - 从 stdin 读 JSON(echo '<json>' | omc call <tool> --args -),或把文件放进配置根后再引用;限制是为了防参数文件携带凭据被收进会话产物` : detail, retryable: false, ...(outOfRoot ? { configurationRoot: configRoot } : {}) }));
524
541
  return CLI_EXIT_CODES.invalidInput;
525
542
  }
526
543
  const registry = buildToolRegistry();
@@ -713,9 +730,21 @@ async function cmdArtifactGet(id, transport, options = {}) {
713
730
  return emitTransportResult(result, transport);
714
731
  }
715
732
  if (options.jq !== undefined) {
716
- const projected = projectArtifactPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
733
+ // A3(GitLab #15):逗号多路径——一次取多列(projections 数组),单路径形态不变。
734
+ const jqPaths = options.jq.split(",").map((path) => path.trim()).filter(Boolean);
735
+ const artifactContent = artifact.content.toString("utf8") === "" ? undefined : content;
736
+ if (jqPaths.length > 1) {
737
+ const projections = jqPaths.map((path) => {
738
+ const projected = projectArtifactPath(artifactContent, path);
739
+ return projected.missed
740
+ ? { path, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }
741
+ : { path, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}) };
742
+ });
743
+ return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, projections, ...(projections.some((entry) => entry.pathMiss) ? { unanswered: true, hint: "逗号多路径中部分路径未命中(见各 projection.pathMiss/missedAt/available);路径语法:点式 a.b、下标 a[0]、切片 a[0:6]、数组映射 items[].fields.x、按键选择 rules[code=Create]" } : {}) }, transport);
744
+ }
745
+ const projected = projectArtifactPath(artifactContent, options.jq);
717
746
  return emitTransportResult(projected.missed
718
- ? { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, unanswered: true, hint: `路径未命中段 "${projected.at}"——available 是该层可用的键/范围;方括号下标(a[0])与点式(a.0)等价;[] 映射数组(items[].fields.x);按键选择 rules[code=Create]` }
747
+ ? { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, unanswered: true, hint: `路径未命中段 "${projected.at}"——available 是该层可用的键/范围;方括号下标(a[0])与点式(a.0)等价;[] 映射数组(items[].fields.x);按键选择 rules[code=Create];一次取多列用逗号分隔路径(--jq "a.b,c.d"),本参数是路径选择器、不是 jq 语言(不支持 | 与对象构造)` }
719
748
  : { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}) }, transport);
720
749
  }
721
750
  // r6-onboarding 2026-10-21: 47 artifact fetches, 0 used a projection; the 10
@@ -749,8 +778,34 @@ async function cmdArtifactGet(id, transport, options = {}) {
749
778
  }, transport);
750
779
  }
751
780
  const result = { status: "ok", artifactId: id, sha256: artifact.sha256, content };
752
- if (Buffer.byteLength(JSON.stringify(result), "utf8") > transport.maxOutputBytes)
753
- return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, fetch: artifactFetchHint(id) }, transport);
781
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") > transport.maxOutputBytes) {
782
+ // 2026-11-04 r70: `--full` used to fall back to the same bare `fetch`
783
+ // pointer it had just been asked to bypass — re-running `--full` re-hits
784
+ // the same ceiling, so a caller that genuinely needs the body loops
785
+ // forever (observed live: three consecutive `--full` calls returned the
786
+ // same id with bytes 90K→151K→227K of pointer metadata). Say what
787
+ // actually happened and give the two projections that DO work.
788
+ const record = content && typeof content === "object" && !Array.isArray(content) ? content : undefined;
789
+ const keys = record
790
+ ? Object.entries(record)
791
+ .map(([key, value]) => ({ key, bytes: Buffer.byteLength(JSON.stringify(value) ?? "null", "utf8") }))
792
+ .sort((left, right) => right.bytes - left.bytes)
793
+ .slice(0, 3)
794
+ .map((entry) => entry.key)
795
+ : [];
796
+ return emitTransportResult({
797
+ status: "ok",
798
+ artifactId: id,
799
+ sha256: artifact.sha256,
800
+ bytes: artifact.content.length,
801
+ fullRejected: true,
802
+ reason: `整包 ${Math.round(artifact.content.length / 1024)}KB 超过本通道输出上限 ${transport.maxOutputBytes}B;--full 无法一次返回,再跑一次结果相同`,
803
+ ...(keys.length > 0 ? { keysCommand: `omc artifact get ${id} --keys ${keys.join(",")}`, sampledKeys: keys } : {}),
804
+ jqCommand: `omc artifact get ${id} --jq '<path>'`,
805
+ raiseCeilingCommand: `omc artifact get ${id} --full --max-output-bytes ${Math.max(artifact.content.length + 1024, transport.maxOutputBytes * 2)}`,
806
+ hint: "二选一:用 --jq/--keys 取你真正需要的那部分(推荐),或显式抬高 --max-output-bytes",
807
+ }, transport);
808
+ }
754
809
  return emitTransportResult(result, transport);
755
810
  }
756
811
  catch (error) {
@@ -1580,14 +1635,16 @@ async function main() {
1580
1635
  commandExitCode = CLI_EXIT_CODES.invalidInput;
1581
1636
  return;
1582
1637
  }
1583
- const rows = store.listEnvironments().map((item) => ({ name: item.name, baseUrl: item.profile.baseUrl, applicationCode: item.profile.applicationCode, isDefault: item.profile.isDefault === true, writable: item.profile.writable !== false }));
1638
+ const rows = store.listEnvironments().map((item) => ({ name: item.name, baseUrl: item.profile.baseUrl, applicationCode: item.profile.applicationCode, isDefault: item.profile.isDefault === true, writable: item.profile.writable !== false, credential: store.hasCredential(item.name) ? "ok" : "missing" }));
1584
1639
  if (options.json)
1585
1640
  process.stdout.write(JSON.stringify({ ok: true, environments: rows }, null, 2) + "\n");
1586
1641
  else if (rows.length === 0)
1587
1642
  console.error("(none configured; run omc setup)");
1643
+ // A5(GitLab #15):credential: missing 的环境列得出、调不通(auth-config)——
1644
+ // 标注出来,防把"没凭据"误判成"参数写错"。
1588
1645
  else
1589
1646
  for (const row of rows)
1590
- console.error(`${row.name}${row.isDefault ? " (default)" : ""} ${row.baseUrl} app=${row.applicationCode}${row.writable ? "" : " [read-only]"}`);
1647
+ console.error(`${row.name}${row.isDefault ? " (default)" : ""} ${row.baseUrl} app=${row.applicationCode}${row.writable ? "" : " [read-only]"}${row.credential === "ok" ? "" : " [credential: missing → omc setup]"}`);
1591
1648
  commandExitCode = CLI_EXIT_CODES.ok;
1592
1649
  });
1593
1650
  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); });
@@ -195,6 +195,23 @@ export class ConfigStore {
195
195
  throw new ConfigurationError(`credentials store has no entry for environment ${name}`);
196
196
  return credential;
197
197
  }
198
+ /**
199
+ * A5(GitLab #15):凭据**存在性**探测(只回 boolean,永不触碰值——凭据卫生)。
200
+ * env list / doctor 用它标注哪些环境真的可用,防「列得出、调不通」的方向性误判。
201
+ */
202
+ hasCredential(name) {
203
+ if (process.env.OMC_CLOUDPIVOT_CREDENTIAL)
204
+ return true;
205
+ if (!existsSync(this.paths.credentialsPath))
206
+ return false;
207
+ try {
208
+ const parsed = JSON.parse(readFileSync(this.paths.credentialsPath, "utf8"));
209
+ return typeof parsed[name]?.username === "string" && typeof parsed[name]?.password === "string";
210
+ }
211
+ catch {
212
+ return false;
213
+ }
214
+ }
198
215
  writeCredential(name, credential) {
199
216
  mkdirSync(dirname(this.paths.credentialsPath), { recursive: true });
200
217
  const existing = existsSync(this.paths.credentialsPath)
package/dist/endpoints.js CHANGED
@@ -91,6 +91,11 @@ export const ENDPOINTS = {
91
91
  businessRulePublish: "/api/api/app/businessrule/publish",
92
92
  businessRuleNodeRunMap: "/api/api/app/businessrule/node_run_map",
93
93
  businessRuleEnable: "/api/api/app/businessrule/enable",
94
+ // 8.6.26 BusinessRuleController.java:517 与 8.1.108 均提供 disable 端点
95
+ // (body={schemaCode,ruleCode})。rule.disable 直打此端点——此前走
96
+ // businessrule/update 拼载荷,正是 2026-11-03 issue #13 在 8.1 上踩出的
97
+ // 形状脆弱路径(残缺实体 → 无明细 50000)。
98
+ businessRuleDisable: "/api/api/app/businessrule/disable",
94
99
  businessRuleDelete: "/api/api/app/businessrule/delete",
95
100
  /**
96
101
  * Manual execution of a CUSTOM-trigger rule (the list/form button path).
@@ -365,11 +365,37 @@ export function registerB2Lane(tools) {
365
365
  const row = fetched.data;
366
366
  if (!row)
367
367
  return { serviceCode: args.serviceCode, methodCode: args.methodCode, present: false, absent: true, method: null };
368
+ const adapterCode = adapterCodeOf(serviceRow);
369
+ const inputParameters = normalizeParameterFaceReadback(adapterCode, decodeJsonColumn(row.inputParametersJson));
370
+ const outputParameters = normalizeParameterFaceReadback(adapterCode, decodeJsonColumn(row.outputParametersJson));
371
+ // A2 双面交叉校验(2026-11-03 issue,8.1.108 实测):get 面(按 id)可能把
372
+ // DB 实有的参数面读成 "[]"(同服务短参数正常、637 字节 3 参数读空——长度
373
+ // 相关的平台读缺陷)。list 面同列非空而 get 面为空时,标记
374
+ // parametersUnreadable 并附 list 面参数,禁止下游按「无参数」处理(曾导致
375
+ // 把配置正确的入参误判为缺失并重配)。
376
+ const crossCheck = (column, face, fromListKey) => {
377
+ const listFace = decodeJsonColumn(listed[column]);
378
+ const listCount = Array.isArray(listFace) ? listFace.length : 0;
379
+ if (listCount === 0 || face.length > 0)
380
+ return null;
381
+ return {
382
+ parametersUnreadable: true,
383
+ listFaceCount: listCount,
384
+ getFaceCount: face.length,
385
+ hint: `${column} 的 get 面(按 id)读回为空,但 list 面实有 ${listCount} 个参数——平台读缺陷(8.1.108 实测),勿按「无参数」处理;${fromListKey} 是 list 面读回的参数,以其为准复核`,
386
+ [fromListKey]: normalizeParameterFaceReadback(adapterCode, listFace),
387
+ };
388
+ };
389
+ const inputCross = crossCheck("inputParametersJson", Array.isArray(inputParameters) ? inputParameters : [], "inputParametersFromList");
390
+ const outputCross = crossCheck("outputParametersJson", Array.isArray(outputParameters) ? outputParameters : [], "outputParametersFromList");
368
391
  const method = {
369
392
  ...row,
370
- configJson: maskServiceConfigSecrets(normalizeMethodConfigReadback(adapterCodeOf(serviceRow), decodeJsonColumn(row.configJson))),
371
- inputParameters: normalizeParameterFaceReadback(adapterCodeOf(serviceRow), decodeJsonColumn(row.inputParametersJson)),
372
- outputParameters: normalizeParameterFaceReadback(adapterCodeOf(serviceRow), decodeJsonColumn(row.outputParametersJson)),
393
+ configJson: maskServiceConfigSecrets(normalizeMethodConfigReadback(adapterCode, decodeJsonColumn(row.configJson))),
394
+ inputParameters,
395
+ outputParameters,
396
+ ...(inputCross || outputCross
397
+ ? { readbackCaveat: { ...(inputCross ?? {}), ...(outputCross ?? {}) } }
398
+ : {}),
373
399
  };
374
400
  return { serviceCode: args.serviceCode, methodCode: args.methodCode, present: true, method };
375
401
  }),
@@ -12,8 +12,12 @@ export const RULE_CREATE_BODY = Object.freeze({
12
12
  status: RULE_STATUS.disabled,
13
13
  defaultRule: false,
14
14
  developmentMode: 0,
15
+ // 8.1 族实体校验要求结构化默认值(缺席 → 平台落 "",update 回无明细 50000);
16
+ // 8.6 同样接受。与设计器新建规则的落库形状一致(2026-11-03 issue A8)。
17
+ options: '{"ruleVariable":[]}',
18
+ extensionSetting: "{}",
15
19
  });
16
- export const RULE_PLATFORM_QUIRK = "enabled is the dispatch truth; status may still read DISABLED after enable — never infer enable state from status";
20
+ export const RULE_PLATFORM_QUIRK = "enabled is the dispatch truth; status may still read DISABLED after enable — never infer enable state from status. Reverse state exists too: enable returning errcode 0 while enabled stays 0 means the ENTITY is incomplete (missing schedulerSetting.methodMapping.methodCode or empty-string options/extensionSetting break the 8.1 update validation) — fix the entity via rule.save, not by retrying enable";
17
21
  const RULE_NODE_GRAPH_EXAMPLE = '[{"nodeCode":"Start","nodeName":"开始","nodeType":"START",x,y,height,width},{"nodeCode":"UPDATE_1","nodeType":"UPDATE","targetObjectCode":"<target schemaCode>","inputParam":"_input","updateNodeType":"UPDATE_MODEL","execFormMapping":false,"filterCondition":{"mainConditions":[[{"targetSchemaDataItem":"<target field>","ruleConditionType":"EQ","ruleValueType":"DYNAMIC","currentSchemaDataItem":"<trigger field>"}]],"sheetConditions":[]},"dataActions":[{"targetDataItem":"<target field>","ruleValueType":"DYNAMIC","value":"","ruleActionType":"EQUALS","currentDataItemValue":"<trigger field>"}],x,y,height,width},{"nodeCode":"End","nodeName":"结束","nodeType":"END",x,y,height,width}]';
18
22
  export const RULE_NODE_GRAPH_WIRE = Object.freeze({
19
23
  inputParam: "_input",
@@ -8,7 +8,7 @@ import { applicationResolutionOf, applicationScopeHint, flattenTreeRows, resolve
8
8
  import { executeRuleSaveWrite, ruleNodeGraphArg } from "../rule-support.js";
9
9
  import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
10
10
  import { resolveRuleRowId } from "../identity.js";
11
- import { wireCall, wireRows } from "../wire.js";
11
+ import { wireCall, wireCallWith404Fallback, wireRows } from "../wire.js";
12
12
  import { BUSINESSRULE_LANE_CONTRACT } from "./businessrule-contract.js";
13
13
  const RULE_LANE = BUSINESSRULE_LANE_CONTRACT.lane;
14
14
  const RULE_TOOLS = BUSINESSRULE_LANE_CONTRACT.tools;
@@ -109,7 +109,16 @@ export function registerBusinessRuleLane(tools) {
109
109
  capability: capabilityArg,
110
110
  },
111
111
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
112
- const rows = wireRows((await wireCall(session, { path: ENDPOINTS.runtimeBusinessRuleListBySchemaCode, query: { schemaCode: args.schemaCode } })).data);
112
+ // 版本族备选(2026-11-03 issue A1):8.6 锚点走 runtime 面;8.1 族
113
+ // runtime 端点缺席(404)时下探 app 面 `businessrule/list_by_schemacode`。
114
+ // 仅 404 下探,401/500/业务码拒绝原样上抛。rule.save / rule.enable 的
115
+ // 读回本就走 app 面,两族都可用。下探发生时在结果里标注命中的端点族,
116
+ // 让调用方能看见自己不在锚点路径上(排查兼容问题时的关键信号)。
117
+ let endpointFamily = null;
118
+ const rows = wireRows((await wireCallWith404Fallback(session, [
119
+ { path: ENDPOINTS.runtimeBusinessRuleListBySchemaCode, query: { schemaCode: args.schemaCode } },
120
+ { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode: args.schemaCode } },
121
+ ], (failed, next) => { endpointFamily = { used: next.path, reason: `${failed.path} HTTP 404(端点在本版本族缺席,已下探)` }; })).data);
113
122
  // N2 (r10 noise pass): the default row is the inventory face —
114
123
  // code/name/state/publish-version/is-preset. The audit columns
115
124
  // (id/createdTime/creator/developmentMode/templateType/…) only ride
@@ -286,6 +295,8 @@ export function registerBusinessRuleLane(tools) {
286
295
  schemaCode: z.string().min(1).optional().describe("业务键定位:模型 code(与 ruleCode 同传,免去先 rule.list 取 id)"),
287
296
  ruleCode: z.string().min(1).optional().describe("业务键定位:规则 code(如 Create/Update/自定义),与 schemaCode 同传"),
288
297
  full: z.boolean().optional().describe("返回原始存储图(含确定性布局 x/y/width/height 与路由 points);默认返回语义图,确定性布局已投影去掉(可直接重存,保存时会重新生成布局)"),
298
+ keys: z.array(z.string().min(1)).min(1).optional().describe("工具经济(A6):只返回列出的顶层键(identity 键 id/code/schemaCode 恒在)。例 ['schedulerSetting'] 只看调度配置,不内联整图——对照生产规则形状时用"),
299
+ summary: z.boolean().optional().describe("工具经济(A6):只返回身份与体量摘要(节点数/路由数/schedulerSetting 是否存在),不含 nodes/routes 正文"),
289
300
  capability: capabilityArg,
290
301
  },
291
302
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
@@ -375,7 +386,7 @@ export function registerBusinessRuleLane(tools) {
375
386
  catch {
376
387
  // 体检失败不破坏读回本身
377
388
  }
378
- return {
389
+ const payload = {
379
390
  id: ruleId,
380
391
  absent: false,
381
392
  code: record.code,
@@ -402,6 +413,30 @@ export function registerBusinessRuleLane(tools) {
402
413
  ...(semanticFindings ? { semanticFindings } : {}),
403
414
  ...(staleRow ? { staleRow, effectiveId, staleRowMessage } : {}),
404
415
  };
416
+ // A6 工具经济(2026-11-03 issue):rule.get 默认内联整图(nodes/routes/
417
+ // schedulerSetting 全量),对照生产规则形状时往往只要一个键。summary 给
418
+ // 身份+体量;keys 保留列出的键 + 恒定的 identity 键。两者都作用于投影后
419
+ // 的返回体,full 语义不变。
420
+ if (args.summary === true) {
421
+ return {
422
+ id: payload.id, code: payload.code, name: payload.name, schemaCode: payload.schemaCode,
423
+ bizRuleType: payload.bizRuleType, enabled: payload.enabled, ruleStatus: payload.ruleStatus,
424
+ defaultRule: payload.defaultRule, ruleVersion: payload.ruleVersion,
425
+ nodeCount: Array.isArray(payload.nodes) ? payload.nodes.length : 0,
426
+ routeCount: Array.isArray(payload.routes) ? payload.routes.length : 0,
427
+ schedulerSettingPresent: payload.schedulerSetting !== null && payload.schedulerSetting !== undefined,
428
+ schedulerSettingDataSourceType: payload.schedulerSetting && typeof payload.schedulerSetting === "object" && !Array.isArray(payload.schedulerSetting)
429
+ ? payload.schedulerSetting.dataSourceType
430
+ : undefined,
431
+ ...(payload.statusLag ? { statusLag: payload.statusLag, statusNote: payload.statusNote } : {}),
432
+ };
433
+ }
434
+ if (args.keys !== undefined) {
435
+ const identity = new Set(["id", "code", "schemaCode"]);
436
+ const wanted = new Set([...args.keys, ...identity]);
437
+ return Object.fromEntries(Object.entries(payload).filter(([key]) => wanted.has(key)));
438
+ }
439
+ return payload;
405
440
  }, { full: args.full === true, compact: ruleCompactProjection, target: `rule.get:${args.id ?? `${args.schemaCode}/${args.ruleCode}`}` }),
406
441
  }));
407
442
  writeTool(tools, "rule.save", RULE_LANE, RULE_TOOLS["rule.save"].scope, RULE_TOOLS["rule.save"].description, {
@@ -74,6 +74,83 @@ function deptTypeCode(value) {
74
74
  return code;
75
75
  }
76
76
  const orgQuery = (session) => ({ corpId: session.enterpriseId() });
77
+ /**
78
+ * Read the role tree (`/role/list?expandAll=true`) and flatten it, keeping the
79
+ * **id** alongside code/name.
80
+ *
81
+ * Why this exists (r70, live 1.1.25): `org.role.list`'s compact projection emits
82
+ * `code|name` pairs and drops the id, while `add_role` / `add_role_user` /
83
+ * `update_ouscope` all want the id. Worse, the platform stores a wrong
84
+ * `roleGroupId` verbatim (`h_org_role.groupId` is not an FK): passing the group
85
+ * *code* that the read face shows yields errcode 0 and an orphan role — the row
86
+ * exists, `role/users` finds it, `role/list` never returns it, `role/get`
87
+ * answers 50000. Resolving code→id here removes the trap for every caller.
88
+ */
89
+ async function readRoleTree(session) {
90
+ const data = (await wireCall(session, {
91
+ path: ENDPOINTS.organizationRoleList,
92
+ query: { corpId: session.enterpriseId(), expandAll: "true" },
93
+ })).data;
94
+ const groups = [];
95
+ const roles = [];
96
+ const childKeys = ["children", "roles", "childRoles", "childList", "list"];
97
+ const childrenOf = (row) => {
98
+ for (const key of childKeys) {
99
+ const value = row[key];
100
+ if (Array.isArray(value))
101
+ return value;
102
+ }
103
+ return [];
104
+ };
105
+ const walk = (rows) => {
106
+ for (const row of rows) {
107
+ const kids = childrenOf(row);
108
+ // A container carries group===true, or has children and no groupId; a leaf
109
+ // role always carries groupId (mirrors flattenRoleTree's rule).
110
+ const hasGroupId = row.groupId !== undefined && row.groupId !== null;
111
+ const isGroup = row.group === true || (!hasGroupId && kids.length > 0);
112
+ if (!isGroup) {
113
+ const id = String(row.id ?? "");
114
+ if (id !== "")
115
+ roles.push({ id, code: String(row.code ?? ""), name: String(row.name ?? "") });
116
+ continue;
117
+ }
118
+ groups.push({ id: String(row.id ?? ""), code: String(row.code ?? ""), name: String(row.name ?? "") });
119
+ if (kids.length > 0)
120
+ walk(kids);
121
+ }
122
+ };
123
+ walk(wireRows(data));
124
+ return { groups, roles };
125
+ }
126
+ /**
127
+ * Resolve a caller-supplied role reference (32-hex id, or the `code` / `name`
128
+ * that `org.role.list` actually shows) to the platform role id. A reference that
129
+ * matches nothing is refused with the list of real roles — never written blind.
130
+ */
131
+ async function resolveRoleRef(session, args, tool) {
132
+ const requested = args.roleId ?? args.roleCode;
133
+ if (!requested)
134
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool, message: "需要 roleId 或 roleCode(org.role.list 的 roles 码表给的是 code)" });
135
+ // An explicit roleCode must resolve — never forward it as an id.
136
+ if (args.roleId !== undefined && args.roleCode === undefined && /^[0-9a-f]{32}$/i.test(requested))
137
+ return requested;
138
+ const { roles } = await readRoleTree(session);
139
+ const match = roles.find((role) => role.code === requested) ?? roles.find((role) => role.name === requested);
140
+ if (match)
141
+ return match.id;
142
+ // Not a known code/name. `roleId` may still be a valid id the tree does not
143
+ // surface (e.g. a role in another corp); forward it and let the platform
144
+ // judge, rather than breaking callers that hold an id we cannot see.
145
+ if (args.roleId !== undefined)
146
+ return requested;
147
+ throw new ToolInputError({
148
+ status: "error",
149
+ blocker: "tool-input-invalid",
150
+ tool,
151
+ message: `角色 ${requested} 不存在。可用角色:${roles.slice(0, 30).map((role) => `${role.code}(${role.name})`).join(";")}`,
152
+ });
153
+ }
77
154
  const roleUserId = (row) => String(row.id ?? row.userId ?? row.recId ?? "");
78
155
  async function memberRows(session, roleId) {
79
156
  const data = (await wireCall(session, {
@@ -226,22 +303,43 @@ export function registerOrganizationLane(tools) {
226
303
  },
227
304
  };
228
305
  });
229
- writeTool(tools, "org.role.create", LANE, SCOPES.roleCreate, "创建角色(界面「分组」;`POST organization/role/add_role`)。返回 roleId;code 创建时 null、随后补 SYS<时间戳>,要稳定编码就传 code。", {
306
+ writeTool(tools, "org.role.create", LANE, SCOPES.roleCreate, "创建角色(界面「分组」;`POST organization/role/add_role`)。返回 roleId;code 创建时 null、随后补 SYS<时间戳>,要稳定编码就传 code。**所属角色组推荐用 `roleGroupCode`(如 SYS_1000000)**:`org.role.list` 的紧凑码表只给 `code|name`、不显示组 id,而平台对错的 roleGroupId **静默接受**——存进去的值不是真实组 id 时角色会变成看不见的孤儿(r70 实测:在库里、role/users 查得到,role/list 永不返回、role/get 50000)。工具会把 code 归一成真实 id,并在创建后回读可见性。", {
230
307
  roleName: z.string().min(1),
231
- roleGroupId: z.string().min(1).describe("所属角色组 id(org.role.list 的 groups[].id)"),
308
+ roleGroupCode: z.string().min(1).optional().describe("所属角色组的 code(org.role.list 的 groups 码表,如 SYS_1000000)或名称。**推荐**;与 roleGroupId 二选一"),
309
+ roleGroupId: z.string().min(1).optional().describe("所属角色组 id(32 位 hex,只能从 artifact get <role.list 的 detailArtifactId> --jq groups 取到)。误传组 code 时工具会归一成真实 id"),
232
310
  roleGroupName: z.string().optional(),
233
311
  roleType: z.enum(["SYS", "CUSTOMIZE", "OTHER"]).optional().describe("默认 SYS(平台会把角色统一落成 SYS,见 RoleHelper.createRoleModel)"),
234
312
  code: z.string().optional(),
235
313
  remarks: z.string().optional(),
236
314
  corpId: z.string().min(1).optional().describe("组织 corpId,默认当前登录企业;传 \"other\" 表示非组织分组角色"),
237
315
  }, async (session, args) => {
316
+ if (!args.roleGroupCode && !args.roleGroupId) {
317
+ throw new ToolInputError({ message: "必须给 roleGroupCode(推荐)或 roleGroupId;先 org.role.list 取 groups 码表" });
318
+ }
319
+ const { groups } = await readRoleTree(session);
320
+ if (groups.length === 0) {
321
+ throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "org.role.list 未解析出任何角色组;先确认组织树可读,不要盲建角色" });
322
+ }
323
+ const requested = args.roleGroupId ?? args.roleGroupCode;
324
+ const match = groups.find((group) => group.id === requested) ??
325
+ groups.find((group) => group.code === requested) ??
326
+ groups.find((group) => group.name === requested);
327
+ // A code/name that resolves to nothing is a definite typo → refuse.
328
+ // A 32-hex id absent from the tree may be a group created moments ago in
329
+ // this same session; let it through and rely on the visibility backstop.
330
+ if (!match && args.roleGroupCode) {
331
+ throw new ToolInputError({
332
+ message: `角色组 ${requested} 不存在。可用组:${groups.map((group) => `${group.code}(id=${group.id},${group.name})`).join(";")}`,
333
+ });
334
+ }
335
+ const roleGroupId = match?.id ?? String(requested);
238
336
  const corpId = args.corpId ?? session.enterpriseId();
239
337
  const data = (await wireCall(session, {
240
338
  path: ENDPOINTS.organizationRoleAdd, method: "POST",
241
339
  query: orgQuery(session),
242
340
  body: {
243
341
  roleName: args.roleName,
244
- roleGroupId: args.roleGroupId,
342
+ roleGroupId,
245
343
  ...(args.roleGroupName ? { roleGroupName: args.roleGroupName } : {}),
246
344
  roleType: args.roleType ?? "SYS",
247
345
  corpId,
@@ -252,15 +350,29 @@ export function registerOrganizationLane(tools) {
252
350
  const roleId = String(data?.roleId ?? data?.id ?? "");
253
351
  if (!roleId)
254
352
  throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "平台未返回 roleId;先 org.role.list 回读确认" });
353
+ // Backstop against the silent-orphan trap: re-read the tree and report the
354
+ // truth instead of reporting success for a role nobody can see.
355
+ const visible = (await readRoleTree(session)).roles.some((role) => role.id === roleId);
255
356
  const readbackCorpId = data?.corpId ?? null;
357
+ const warnings = [];
358
+ if (!visible) {
359
+ warnings.push(`角色 ${roleId} 创建后不在 org.role.list 任何组下(孤儿角色):groupId 很可能没落到真实组 id。请 org.role.delete 后按 roleGroupCode 重建`);
360
+ }
361
+ if (readbackCorpId !== null && String(readbackCorpId) !== corpId) {
362
+ warnings.push(`平台回读 corpId=${String(readbackCorpId)},与请求的 ${corpId} 不一致`);
363
+ }
256
364
  return {
257
365
  performed: { errcode: 0 },
258
366
  readback: {
259
367
  roleId,
260
368
  roleName: data?.roleName ?? args.roleName,
261
369
  code: data?.code ?? null,
370
+ roleGroupId,
371
+ roleGroupCode: match?.code ?? null,
372
+ roleGroupName: match?.name ?? null,
373
+ visibleInRoleList: visible,
262
374
  corpId: readbackCorpId,
263
- ...(readbackCorpId === null || String(readbackCorpId) === corpId ? {} : { warning: `平台回读 corpId=${String(readbackCorpId)},与请求的 ${corpId} 不一致` }),
375
+ ...(warnings.length > 0 ? { warning: warnings.join(";") } : {}),
264
376
  },
265
377
  };
266
378
  });
@@ -634,21 +746,23 @@ export function registerOrganizationLane(tools) {
634
746
  return { userId: String(args.userId), count: departments.length, departments };
635
747
  }),
636
748
  }));
637
- writeTool(tools, "org.role.delete", LANE, SCOPES.roleDelete, "删除角色(`GET organization/role/strike_out_role?ids=`)。平台**拒绝删除仍有成员的角色**(`-1 角色下已经包含用户数据!`,实测 1.1.25):默认先读成员数、非空即拒绝并给出人数;`force:true` 才会先逐人移出再删。", {
638
- roleId: z.string().min(1),
749
+ writeTool(tools, "org.role.delete", LANE, SCOPES.roleDelete, "删除角色(`GET organization/role/strike_out_role?ids=`)。平台**拒绝删除仍有成员的角色**(`-1 角色下已经包含用户数据!`,实测 1.1.25):默认先读成员数、非空即拒绝并给出人数;`force:true` 才会先逐人移出再删。**可用 roleCode 代替 roleId**(org.role.list 的码表只有 code)。", {
750
+ roleId: z.string().min(1).optional().describe("角色 id(32 位 hex);与 roleCode 二选一"),
751
+ roleCode: z.string().min(1).optional().describe("角色 code(org.role.list 的 roles 码表,如 qc_manager)或名称"),
639
752
  force: z.boolean().optional().describe("true = 先移出全部成员再删角色"),
640
753
  }, async (session, args) => {
641
- const rows = await memberRows(session, String(args.roleId));
754
+ const roleId = await resolveRoleRef(session, { roleId: args.roleId, roleCode: args.roleCode }, "org.role.delete");
755
+ const rows = await memberRows(session, roleId);
642
756
  if (rows.length > 0 && args.force !== true)
643
757
  throw new ToolRefusalError({
644
- status: "refused", blocker: "role-has-members", roleId: String(args.roleId), members: rows.length,
645
- nextCommand: `omc call org.role.delete --args '${JSON.stringify({ roleId: String(args.roleId), force: true })}'`,
758
+ status: "refused", blocker: "role-has-members", roleId, members: rows.length,
759
+ nextCommand: `omc call org.role.delete --args '${JSON.stringify({ roleId, force: true })}'`,
646
760
  message: `角色下还有 ${rows.length} 名成员,平台拒绝删除(errmsg:角色下已经包含用户数据)。先 org.role.removeUsers,或传 force:true 让工具移出后再删。`,
647
761
  });
648
762
  if (rows.length > 0)
649
- await removeMembers(session, String(args.roleId), rows.map(roleUserId));
650
- await wireCall(session, { path: ENDPOINTS.organizationRoleDelete, query: { ...orgQuery(session), ids: String(args.roleId) } });
651
- return { performed: { errcode: 0 }, readback: { roleId: String(args.roleId), deleted: true, removedMembers: rows.length } };
763
+ await removeMembers(session, roleId, rows.map(roleUserId));
764
+ await wireCall(session, { path: ENDPOINTS.organizationRoleDelete, query: { ...orgQuery(session), ids: roleId } });
765
+ return { performed: { errcode: 0 }, readback: { roleId, deleted: true, removedMembers: rows.length } };
652
766
  });
653
767
  writeTool(tools, "org.roleGroup.delete", LANE, SCOPES.roleGroupDelete, "删除角色组(`GET organization/role/strike_out_role_group?ids=`)。平台**拒绝删除仍含角色的组**(`-1 角色组下已经包含角色数据!`):默认先数角色、非空即拒绝;`force:true` 会逐个删角色(含其成员)再删组。", {
654
768
  roleGroupId: z.string().min(1),
@@ -730,7 +844,8 @@ export function registerOrganizationLane(tools) {
730
844
  };
731
845
  });
732
846
  writeTool(tools, "org.role.addUsers", LANE, SCOPES.addUsers, "加成员并(可选)一次设好管理范围(`add_role_user` + 逐人 `update_ouscope`,一个工具调用)。users[].id 必须是 recId(org.user.search 的 id);传 `scopes` 会应用到本次新增的每个人。**平台的 add_role_user 虽然收 `ouScope` 但会忽略它**(实测 1.1.25,回读仍为空),所以范围必须单独写——工具内部已按顺序完成。加完回读成员与范围。", {
733
- roleId: z.string().min(1),
847
+ roleId: z.string().min(1).optional().describe("角色 id(32 位 hex);与 roleCode 二选一"),
848
+ roleCode: z.string().min(1).optional().describe("角色 code(org.role.list 的 roles 码表)或名称——省掉取 artifact 找 id"),
734
849
  users: z.array(z.object({ id: z.string().min(1).describe("用户记录 id(recId)"), name: z.string().min(1) })).min(1),
735
850
  scopes: z.array(z.object({
736
851
  id: z.string().min(1).describe("部门 id"),
@@ -744,15 +859,16 @@ export function registerOrganizationLane(tools) {
744
859
  const users = args.users ?? args.userIds ?? [];
745
860
  if (users.length === 0)
746
861
  throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "org.role.addUsers", message: "需要 users(或 userIds)至少一人" });
862
+ const roleId = await resolveRoleRef(session, { roleId: args.roleId, roleCode: args.roleCode }, "org.role.addUsers");
747
863
  await wireCall(session, {
748
864
  path: ENDPOINTS.organizationRoleAddUser, method: "POST",
749
865
  query: orgQuery(session),
750
- body: { roleId: args.roleId, sortKey: args.sortKey ?? 999, userIds: users.map((user) => ({ id: user.id, name: user.name, type: 3, unitType: "3" })) },
866
+ body: { roleId, sortKey: args.sortKey ?? 999, userIds: users.map((user) => ({ id: user.id, name: user.name, type: 3, unitType: "3" })) },
751
867
  });
752
868
  const readback = async () => {
753
869
  const data = (await wireCall(session, {
754
870
  path: ENDPOINTS.organizationRoleUsers,
755
- query: { ...orgQuery(session), roleId: String(args.roleId), page: "0", size: "500" },
871
+ query: { ...orgQuery(session), roleId: roleId, page: "0", size: "500" },
756
872
  })).data;
757
873
  return Array.isArray(data?.content) ? data.content : wireRows(data);
758
874
  };
@@ -766,11 +882,11 @@ export function registerOrganizationLane(tools) {
766
882
  for (const id of added) {
767
883
  await wireCall(session, {
768
884
  path: ENDPOINTS.organizationRoleUpdateOuScope, method: "POST", query: orgQuery(session),
769
- body: { roleId: String(args.roleId), userId: id, unitType: 3, sortKey: args.sortKey ?? 999, ouScopeList },
885
+ body: { roleId: roleId, userId: id, unitType: 3, sortKey: args.sortKey ?? 999, ouScopeList },
770
886
  });
771
887
  const scope = wireRows((await wireCall(session, {
772
888
  path: ENDPOINTS.organizationRoleGetOuScope,
773
- query: { ...orgQuery(session), roleId: String(args.roleId), userId: id },
889
+ query: { ...orgQuery(session), roleId: roleId, userId: id },
774
890
  })).data);
775
891
  scopeResults.push({ userId: id, scopes: scope.length });
776
892
  }
@@ -778,7 +894,7 @@ export function registerOrganizationLane(tools) {
778
894
  return {
779
895
  performed: { errcode: 0 },
780
896
  readback: {
781
- roleId: String(args.roleId), added,
897
+ roleId: roleId, added,
782
898
  ...(missing.length > 0 ? { missing, hint: "这些 recId 未出现在成员列表:确认 id 是 recId、用户未离职/禁用" } : {}),
783
899
  ...(scopeResults.length > 0 ? { scopeResults } : {}),
784
900
  // ouScope 是流程角色参与人解析的过滤维度:平台
@@ -802,12 +918,14 @@ export function registerOrganizationLane(tools) {
802
918
  };
803
919
  });
804
920
  writeTool(tools, "org.role.removeUsers", LANE, SCOPES.removeUsers, "把成员移出角色(`POST organization/role/strike_out_role_user`)。**平台要求 JSON body `{roleId,userIds:[...]}`**——用 query 或 `ids` 会报 10009 `Required request body is missing`(实测 1.1.25)。只移出角色,不删用户。", {
805
- roleId: z.string().min(1),
921
+ roleId: z.string().min(1).optional().describe("角色 id(32 位 hex);与 roleCode 二选一"),
922
+ roleCode: z.string().min(1).optional().describe("角色 code(org.role.list 的 roles 码表)或名称"),
806
923
  ids: z.array(z.string().min(1)).min(1).describe("要移出的成员 recId 列表"),
807
924
  }, async (session, args) => {
925
+ const roleId = await resolveRoleRef(session, { roleId: args.roleId, roleCode: args.roleCode }, "org.role.removeUsers");
808
926
  await wireCall(session, {
809
927
  path: ENDPOINTS.organizationRoleRemoveUser, method: "POST", query: orgQuery(session),
810
- body: { roleId: String(args.roleId), userIds: args.ids },
928
+ body: { roleId, userIds: args.ids },
811
929
  });
812
930
  const data = (await wireCall(session, {
813
931
  path: ENDPOINTS.organizationRoleUsers,
@@ -818,7 +936,8 @@ export function registerOrganizationLane(tools) {
818
936
  return { performed: { errcode: 0 }, readback: { roleId: String(args.roleId), removed: args.ids.filter((id) => !present.has(id)), stillPresent: args.ids.filter((id) => present.has(id)), total: data?.totalElements ?? rows.length } };
819
937
  });
820
938
  writeTool(tools, "org.role.setUserScope", LANE, SCOPES.setScope, "设置成员管理范围(`POST organization/role/update_ouscope`)。平台覆盖语义、无回滚:默认读旧值合并(mode:\"merge\"),mode:\"replace\" 才整体替换。unitType 传数字 3(addUsers 里是字符串 \"3\")。节点 {id,name,type:\"1\",deptPath}。", {
821
- roleId: z.string().min(1),
939
+ roleId: z.string().min(1).optional().describe("角色 id(32 位 hex);与 roleCode 二选一"),
940
+ roleCode: z.string().min(1).optional().describe("角色 code(org.role.list 的 roles 码表)或名称"),
822
941
  userId: z.string().min(1).describe("成员 recId"),
823
942
  scopes: z.array(z.object({
824
943
  id: z.string().min(1).describe("部门 id"),
@@ -832,9 +951,10 @@ export function registerOrganizationLane(tools) {
832
951
  mode: z.enum(["merge", "replace"]).optional().describe("默认 merge(读旧值合并);replace = 覆盖整个范围"),
833
952
  dryRun: z.boolean().optional(),
834
953
  }, async (session, args) => {
954
+ const roleId = await resolveRoleRef(session, { roleId: args.roleId, roleCode: args.roleCode }, "org.role.setUserScope");
835
955
  const before = wireRows((await wireCall(session, {
836
956
  path: ENDPOINTS.organizationRoleGetOuScope,
837
- query: { ...orgQuery(session), roleId: args.roleId, userId: args.userId },
957
+ query: { ...orgQuery(session), roleId, userId: args.userId },
838
958
  })).data);
839
959
  const wanted = args.scopes.map((scope) => ({ id: scope.id, name: scope.name, type: scope.type === undefined ? "1" : String(scope.type), ...(scope.deptPath ? { deptPath: scope.deptPath } : {}) }));
840
960
  const merged = args.mode === "replace"
@@ -850,15 +970,15 @@ export function registerOrganizationLane(tools) {
850
970
  await wireCall(session, {
851
971
  path: ENDPOINTS.organizationRoleUpdateOuScope, method: "POST",
852
972
  query: orgQuery(session),
853
- body: { roleId: args.roleId, userId: args.userId, unitType: 3, sortKey: 999, ouScopeList: merged },
973
+ body: { roleId, userId: args.userId, unitType: 3, sortKey: 999, ouScopeList: merged },
854
974
  });
855
975
  const after = wireRows((await wireCall(session, {
856
976
  path: ENDPOINTS.organizationRoleGetOuScope,
857
- query: { ...orgQuery(session), roleId: args.roleId, userId: args.userId },
977
+ query: { ...orgQuery(session), roleId, userId: args.userId },
858
978
  })).data).map((row) => ({ id: row.id ?? null, name: row.name ?? null, type: row.type ?? null, deptPath: row.deptPath ?? null }));
859
979
  return {
860
980
  performed: { errcode: 0 },
861
- readback: { roleId: args.roleId, userId: args.userId, mode: args.mode ?? "merge", before: before.length, after: after.length, scopes: after, ...(removed.length > 0 ? { removedScopeIds: removed } : {}) },
981
+ readback: { roleId, userId: args.userId, mode: args.mode ?? "merge", before: before.length, after: after.length, scopes: after, ...(removed.length > 0 ? { removedScopeIds: removed } : {}) },
862
982
  };
863
983
  });
864
984
  }
@@ -119,7 +119,14 @@ export async function executeRuleEnableWrite(session, args) {
119
119
  message: `规则 ${args.ruleCode} 启用时被平台自检拒绝(550018)。${detail.nodeChecks.length > 0 ? `问题节点:${JSON.stringify(detail.nodeChecks).slice(0, 600)}` : "先用 field.list(all:true) 核对引用的字段是否已发布。"}`,
120
120
  });
121
121
  }
122
- throw error;
122
+ // 版本族备选(2026-11-03 issue A1 更正,8.1.108 实测):8.1 族没有
123
+ // `businessrule/publish` 端点(HTTP 404)。已验证的等价路径是
124
+ // `businessrule/update` 携完整实体 + enabled:true —— 实体完整时一次调用
125
+ // 同时翻转 enabled 与 status(见 issue 复现脚本)。仅 404 下探,其余原样抛。
126
+ const publishEndpointAbsent = error instanceof WireError && error.kind === "http-status" && error.diagnostic?.httpStatus === 404;
127
+ if (!publishEndpointAbsent)
128
+ throw error;
129
+ published = await wireCall(session, { path: ENDPOINTS.businessRuleUpdate, method: "POST", body: { ...full, enabled: true, status: "ENABLED" } });
123
130
  }
124
131
  let enableErrcode = 0;
125
132
  let alreadyEnabled = false;
@@ -147,7 +154,10 @@ export function registerRuleLifecycleLane(tools) {
147
154
  const row = wireRows(list.data).find((item) => item.code === args.ruleCode);
148
155
  if (!row?.id)
149
156
  throw new WireError("business", `rule ${args.ruleCode} not found for disable`, 302024);
150
- const performed = await wireCall(session, { path: ENDPOINTS.businessRuleUpdate, method: "POST", body: { ...row, enabled: false, status: "DISABLED" } });
157
+ // 2026-11-04(源码实证,GitLab #13):8.6 与 8.1 都有 businessrule/disable
158
+ // 端点(body={schemaCode,ruleCode}),UI 开关同款——直打,不再用 update
159
+ // 拼载荷(残缺实体在 8.1 会回无明细 50000,正是 issue 的 disable 故障形态)。
160
+ const performed = await wireCall(session, { path: ENDPOINTS.businessRuleDisable, method: "POST", body: { schemaCode: args.schemaCode, ruleCode: args.ruleCode } });
151
161
  const readback = await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode: args.schemaCode } });
152
162
  const after = wireRows(readback.data).find((item) => item.code === args.ruleCode);
153
163
  return { performed: { errcode: performed.errcode }, readback: { enabled: after?.enabled === true } };
@@ -12,6 +12,40 @@ import { CHILD_TABLE_PROPERTY_TYPE, diagnoseApprovalSubmitIdentity, runtimeWrite
12
12
  import { getReplayToken, searchWorkItems, wireString } from "../workflow-support.js";
13
13
  import { ENGINE_OWNED_KEYS, RUNTIME_DATA_LANE_CONTRACT, RUNTIME_DATA_WIRE, runtimeFormDataArg, } from "./runtime-data-contract.js";
14
14
  import { SYSTEM_FIELD_CODES } from "../field-families.js";
15
+ // A2(GitLab #15,2026-11-04):单选/字典字段(RADIO/CHECKBOX/DROPDOWN_BOX/
16
+ // DROPDOWN_MULTI_BOX)在运行面查询按**字典 key**匹配,不按显示值——按「已审核」
17
+ // 这类显示值过滤会静默 0 条;加 `_key` 后缀的字段名则不在发布元数据里,直接拒。
18
+ // 两个方向都给写法提示,防"total:0 被读成查不到数据"(实测 313 条被误读)。
19
+ const CHOICE_FILTER_PROPERTY_TYPES = new Set([12, 13, 14, 15]);
20
+ function collectConditionLeafFields(condition) {
21
+ const codes = [];
22
+ const walk = (node) => {
23
+ if (Array.isArray(node)) {
24
+ node.forEach(walk);
25
+ return;
26
+ }
27
+ if (node === null || typeof node !== "object")
28
+ return;
29
+ const leaf = node;
30
+ if (typeof leaf.propertyCode === "string" && leaf.propertyCode)
31
+ codes.push(leaf.propertyCode);
32
+ Object.values(leaf).forEach(walk);
33
+ };
34
+ walk(condition);
35
+ return [...new Set(codes)];
36
+ }
37
+ function choiceKeySuffixHint(missing, typeByCode) {
38
+ const bases = missing
39
+ .filter((code) => code.endsWith("_key"))
40
+ .map((code) => code.slice(0, -"_key".length))
41
+ .filter((base) => CHOICE_FILTER_PROPERTY_TYPES.has(typeByCode.get(base) ?? -1));
42
+ if (bases.length === 0)
43
+ return "";
44
+ return `;${bases.join("、")} 是单选/多选(字典来源)字段:列表虽同时返回 code 与 code_key,但过滤要用原字段名 + 字典 key 值(不是 _key 后缀的字段名、也不是显示值);key 可用 dictionary.names 查,或改 rawCondition 绕过字段元数据校验`;
45
+ }
46
+ function choiceFilterValueHint(fields) {
47
+ return `total:0 且过滤字段 ${fields.join("、")} 是单选/多选(字典来源):平台查询按字典 key 匹配而非显示值——若 value 用的是显示值(如「已审核」),换字典 key(如 YSH,dictionary.names 可查)再试;确认确无数据再下「查不到」结论;也可用 rawCondition 或先无过滤查询复核 total`;
48
+ }
15
49
  const RUNTIME_LANE = RUNTIME_DATA_LANE_CONTRACT.lane;
16
50
  const RUNTIME_TOOLS = RUNTIME_DATA_LANE_CONTRACT.tools;
17
51
  /**
@@ -789,6 +823,8 @@ export function registerRuntimeDataLane(tools) {
789
823
  throw error;
790
824
  }
791
825
  return governedRead(ctx, self, args, async (session) => {
826
+ // A2:DSL 过滤命中的单选/字典字段(total:0 时给值写法提示)
827
+ let choiceFilterFields = [];
792
828
  // Published field metadata is the sole source for filter propertyType
793
829
  // injection AND for the projectionHint below. Fetch at most once per
794
830
  // call: the DSL path needs it up front; the hint path asks only after
@@ -813,9 +849,13 @@ export function registerRuntimeDataLane(tools) {
813
849
  status: "refused",
814
850
  blocker: "unknown-filter-field",
815
851
  missing: injected.missing,
816
- message: `filter fields not found in published field metadata: ${injected.missing.join(", ")}; the runtime query requires each leaf's propertyType from field metadata (no guessing); system/foreign fields may use rawCondition`,
852
+ message: `filter fields not found in published field metadata: ${injected.missing.join(", ")}; the runtime query requires each leaf's propertyType from field metadata (no guessing); system/foreign fields may use rawCondition` + choiceKeySuffixHint(injected.missing, typeByCode),
817
853
  };
818
854
  condition = injected.condition;
855
+ // A2(GitLab #15):单选/字典字段按显示值过滤会静默 0 条(平台查询按
856
+ // 字典 key 匹配)。带出这些字段,total:0 时给写法提示,防"查不到数据"
857
+ // 误报(实测 313 条被 total:0 误读成"今天没有已审核单")。
858
+ choiceFilterFields = collectConditionLeafFields(condition).filter((code) => CHOICE_FILTER_PROPERTY_TYPES.has(typeByCode.get(code) ?? -1));
819
859
  }
820
860
  const requestedFields = args.displayFields ?? args.columns;
821
861
  const fields = requestedFields && requestedFields.length > 0 ? requestedFields : undefined;
@@ -840,7 +880,7 @@ export function registerRuntimeDataLane(tools) {
840
880
  const firstPage = extractBizObjectPage(first.data);
841
881
  const total = Number(firstPage.total ?? 0);
842
882
  if (total === 0 || firstPage.content.length === 0)
843
- return { schemaCode: args.schemaCode, total: 0, page: "all", size: 0, pagesFetched: 0, items: [] };
883
+ return { schemaCode: args.schemaCode, total: 0, page: "all", size: 0, pagesFetched: 0, items: [], ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
844
884
  const rows = [...firstPage.content];
845
885
  const target = Math.min(total, CAP);
846
886
  let pageIndex = 1;
@@ -860,7 +900,22 @@ export function registerRuntimeDataLane(tools) {
860
900
  const selected = fields ?? Object.keys(data).filter((field) => !["id", "bizObjectId", "schemaCode", "sequenceStatus", "createdTime", "updatedTime", "creator", "updater"].includes(field));
861
901
  return { id: row.bizObjectId ?? row.id ?? data.id, fields: Object.fromEntries(selected.map((field) => [field, data[field] ?? null])) };
862
902
  });
863
- return { schemaCode: args.schemaCode, total, page: "all", size: rows.length, pagesFetched: pageIndex, ...(capped ? { capped: true, capNotice: `结果超过安全上限 ${CAP},已截断;请加 filters 分批` } : {}), items };
903
+ // A6(GitLab #15):all:true 路径此前完全没有 projectionHint——未配置列
904
+ // 的 null/省略同样要点名,防「全量拉取却被投影坑」(实测 orderChange)。
905
+ let allHint;
906
+ if (items.length > 0) {
907
+ try {
908
+ const typeByCode = await publishedTypeByCode();
909
+ const returnedKeys = new Set(Object.keys(items[0]?.fields ?? {}));
910
+ const hintColumns = [...typeByCode.keys()].filter((field) => !SYSTEM_FIELD_CODES.has(field) && (!returnedKeys.has(field) || items.every((item) => item.fields[field] === null)));
911
+ if (hintColumns.length > 0)
912
+ allHint = `这些列在默认视图未配置(值可能存在但列表投影为 null 或整列省略):${hintColumns.join("、")};先用 listview.configure 配置列,或用 data.load 读单条权威值`;
913
+ }
914
+ catch {
915
+ // 元数据读失败不阻断数据返回
916
+ }
917
+ }
918
+ return { schemaCode: args.schemaCode, total, page: "all", size: rows.length, pagesFetched: pageIndex, ...(capped ? { capped: true, capNotice: `结果超过安全上限 ${CAP},已截断;请加 filters 分批` } : {}), ...(allHint ? { projectionHint: allHint } : {}), items };
864
919
  }
865
920
  let result;
866
921
  try {
@@ -868,7 +923,7 @@ export function registerRuntimeDataLane(tools) {
868
923
  }
869
924
  catch (error) {
870
925
  if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode) {
871
- return { schemaCode: args.schemaCode, total: 0, totalPages: 0, page: args.page ?? 0, size: args.size ?? 10, items: [] };
926
+ return { schemaCode: args.schemaCode, total: 0, totalPages: 0, page: args.page ?? 0, size: args.size ?? 10, items: [], ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
872
927
  }
873
928
  // r30: a MISSING model answers this face with a generic 50000
874
929
  // 服务异常 — indistinguishable from a real outage. One field-face
@@ -921,6 +976,7 @@ export function registerRuntimeDataLane(tools) {
921
976
  // (live 2026-10-18: total:0 was read as "no residue" while 9 drafts
922
977
  // existed).
923
978
  filter: { excludedDraft: true, hint: "默认不含 DRAFT(total 亦仅计非草稿);核对/清理残留请加 all:true" },
979
+ ...(choiceFilterFields.length > 0 && Number(page.total ?? 0) === 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}),
924
980
  totalPages: page.totalPages,
925
981
  page: page.page ?? args.page ?? 0,
926
982
  size: page.size ?? args.size ?? 20,
@@ -942,6 +998,26 @@ export function registerRuntimeDataLane(tools) {
942
998
  const typeByCode = await publishedTypeByCode();
943
999
  return candidates.filter((field) => typeByCode.has(field));
944
1000
  };
1001
+ // A6(GitLab #15,2026-11-04):未配置列有两种投影形态——值为 null,或
1002
+ // **整键省略**(orderChange 实测:sequenceNo 在 data.list 里根本不出现,
1003
+ // data.load 却有值,被误读成「单号为空」)。省略检测需要发布元数据
1004
+ // (fieldList 有 60s wire 缓存,DSL 过滤路径本就已取);两种形态合并进 hint。
1005
+ const projectionHintFor = async () => {
1006
+ if (items.length === 0)
1007
+ return undefined;
1008
+ const typeByCode = await publishedTypeByCode();
1009
+ const returnedKeys = new Set(Object.keys(items[0]?.fields ?? {}));
1010
+ const nullColumns = fields && fields.length > 0
1011
+ ? fields.filter((field) => typeByCode.has(field) && items.every((item) => item.fields[field] === null))
1012
+ : [...returnedKeys].filter((field) => !SYSTEM_FIELD_CODES.has(field) && typeByCode.has(field) && items.every((item) => item.fields[field] === null));
1013
+ const omittedColumns = !fields
1014
+ ? [...typeByCode.keys()].filter((field) => !SYSTEM_FIELD_CODES.has(field) && !returnedKeys.has(field))
1015
+ : [];
1016
+ const hintColumns = [...new Set([...nullColumns, ...omittedColumns])];
1017
+ if (hintColumns.length === 0)
1018
+ return undefined;
1019
+ return `这些列在默认视图未配置(值可能存在但列表投影为 null 或整列省略):${hintColumns.join("、")};先用 listview.configure 配置列,或用 data.load 读单条权威值`;
1020
+ };
945
1021
  if (fields &&
946
1022
  fields.length > 0 &&
947
1023
  items.length > 0 &&
@@ -955,9 +1031,9 @@ export function registerRuntimeDataLane(tools) {
955
1031
  // business fields absent from the list view render as null in every
956
1032
  // row with no explanation. Name the null'd business columns so the
957
1033
  // agent reads data.load instead of misdiagnosing "data lost".
958
- const nullColumns = await businessNullColumns();
959
- if (nullColumns.length > 0)
960
- readback.projectionHint = `这些列在默认视图未配置(值可能存在但列表投影为 null):${nullColumns.join("、")};先用 listview.configure 配置列,或用 data.load 读单条权威值`;
1034
+ const hint = await projectionHintFor();
1035
+ if (hint)
1036
+ readback.projectionHint = hint;
961
1037
  }
962
1038
  return readback;
963
1039
  }, { full: args.full === true, target: `data.list:${JSON.stringify({ schemaCode: args.schemaCode, queryCode: args.queryCode ?? args.schemaCode, filter: args.filter, filters: args.filters, filterGroup: args.filterGroup, rawCondition: args.rawCondition, displayFields: args.displayFields, columns: args.columns, page: args.page ?? 0, size: args.size ?? 10 })}` });
@@ -144,7 +144,7 @@ function argvValues(args, name) {
144
144
  return values;
145
145
  }
146
146
  /** Shared wording for every projection miss, so all three faces teach the same syntax. */
147
- const PATH_MISS_HINT = "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、切片 a[0:6]、数组映射 items[].fields.x、按键选择 rules[code=Create] 或 data.activities[code=A1](数组与 index→条目对象都支持;键名可省 activityCode/nodeCode 等别名;返回第一个匹配项)。available 是该层真实存在的键。";
147
+ const PATH_MISS_HINT = "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、切片 a[0:6]、数组映射 items[].fields.x、按键选择 rules[code=Create] 或 data.activities[code=A1](数组与 index→条目对象都支持;键名可省 activityCode/nodeCode 等别名;返回第一个匹配项)。available 是该层真实存在的键。注意:本参数是路径选择器、不是 jq 语言(不支持 | 管道与对象构造);一次取多列用逗号分隔(--jq \"a.b,c.d\")或重复传 --jq。";
148
148
  /**
149
149
  * `call --extract/--extract-keys`: resolve paths inside the result's own
150
150
  * artifact (or inside the result object when the tool returned no artifact).
package/dist/read.js CHANGED
@@ -54,7 +54,7 @@ export async function governedRead(ctx, tool, args, execute, options = {}) {
54
54
  const targetFingerprint = sha256(result);
55
55
  const cached = ctx.readbackCache.get(target);
56
56
  if (ctx.readbackCacheBypass === 0 && cached && cached.environmentFingerprint === environmentFingerprint && cached.targetFingerprint === targetFingerprint && options.full !== true)
57
- return { status: "ok", unchanged: true, detailFile: cached.detailFile, detailSha256: cached.detailSha256, environmentFingerprint };
57
+ return { status: "ok", unchanged: true, detailFile: cached.detailFile, detailSha256: cached.detailSha256, ...(cached.shapePlaceholders ?? {}), environmentFingerprint };
58
58
  const detail = { status: "ok", ...result, environmentFingerprint };
59
59
  // 2026-11-01 预算批 v2(r38 usage 实证):1-4KB 中结果 59 次/135KB≈37% 全内联,
60
60
  // model.inspect 17 次全部卡在旧门下。降到 2048;compact 对配置清单保形为码表
@@ -62,19 +62,26 @@ export async function governedRead(ctx, tool, args, execute, options = {}) {
62
62
  const large = jsonBytes(result) > 2048;
63
63
  const fileName = `${tool.name.replace(/[^a-zA-Z0-9._-]/g, "_")}-${sha256(target).slice(0, 16)}.json`;
64
64
  const artifactRef = writeArtifact(ctx, fileName, detail);
65
- ctx.readbackCache.set(target, { environmentFingerprint, targetFingerprint, detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256 });
65
+ const cacheEntry = { environmentFingerprint, targetFingerprint, detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256 };
66
66
  if (options.full === true) {
67
67
  const rawBytes = jsonBytes(result);
68
68
  // Small raw bodies keep the old inline behaviour; large ones are dieted
69
69
  // (see FULL_INLINE_CEILING_BYTES). The raw body stays fully reachable via
70
70
  // the artifact, so no capability is lost — only the inline copy is skipped.
71
- if (rawBytes <= FULL_INLINE_CEILING_BYTES)
71
+ if (rawBytes <= FULL_INLINE_CEILING_BYTES) {
72
+ ctx.readbackCache.set(target, cacheEntry);
72
73
  return { ...detail, detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256 };
74
+ }
73
75
  const compact = options.compact ? options.compact(result, session) : compactReadback(result);
76
+ // A1:外置与内联同形——被折叠的 payload 键补回空占位;缓存同样记占位,
77
+ // unchanged 二次读回时不丢形状(GitLab #15)。
78
+ const placeholders = externalizedShapePlaceholders(result, droppedShapeKeys(result, compact));
79
+ ctx.readbackCache.set(target, { ...cacheEntry, shapePlaceholders: placeholders });
74
80
  return {
75
81
  status: "ok",
76
82
  ...passthroughScalars(result),
77
83
  ...compact,
84
+ ...placeholders,
78
85
  full: true,
79
86
  fullOmitted: true,
80
87
  fullBytes: rawBytes,
@@ -84,10 +91,14 @@ export async function governedRead(ctx, tool, args, execute, options = {}) {
84
91
  detailSha256: artifactRef.detailSha256,
85
92
  };
86
93
  }
87
- if (!large)
94
+ if (!large) {
95
+ ctx.readbackCache.set(target, cacheEntry);
88
96
  return { status: "ok", ...result, environmentFingerprint };
97
+ }
89
98
  const compact = options.compact ? options.compact(result, session) : compactReadback(result);
90
- return { status: "ok", ...passthroughScalars(result), ...compact, environmentFingerprint, detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256 };
99
+ const placeholders = externalizedShapePlaceholders(result, droppedShapeKeys(result, compact));
100
+ ctx.readbackCache.set(target, { ...cacheEntry, shapePlaceholders: placeholders });
101
+ return { status: "ok", ...passthroughScalars(result), ...compact, ...placeholders, environmentFingerprint, detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256 };
91
102
  }
92
103
  catch (error) {
93
104
  if (isUncertainWireError(error))
@@ -97,6 +108,22 @@ export async function governedRead(ctx, tool, args, execute, options = {}) {
97
108
  throw error;
98
109
  }
99
110
  }
111
+ /**
112
+ * A1(GitLab #15,2026-11-04):外置结果与内联同形。compact 把 BULKY_READ_KEYS
113
+ * (items/data/…)折叠成 `*Count` 后,按内联形状取数的脚本读 `result.items` /
114
+ * `result.data` 直接 TypeError——外置只是把数据搬进 artifact,不应改变返回形状。
115
+ * 被折叠的键补回为空占位(原值是数组→[],否则 null)并标记 truncatedKeys;
116
+ * 真实数据在 detailFile 指向的 artifact 里,根键与内联一致(list=items、load=data)。
117
+ */
118
+ function externalizedShapePlaceholders(result, dropped) {
119
+ const placeholders = { truncatedKeys: [...dropped], truncated: true };
120
+ for (const key of dropped)
121
+ placeholders[key] = Array.isArray(result[key]) ? [] : null;
122
+ return placeholders;
123
+ }
124
+ function droppedShapeKeys(result, payload) {
125
+ return Object.keys(result).filter((key) => BULKY_READ_KEYS.has(key) && !(key in payload));
126
+ }
100
127
  function compactReadback(result) {
101
128
  const compact = {};
102
129
  for (const [key, value] of Object.entries(result)) {
@@ -167,6 +167,88 @@ const ruleTimingTask = Object.freeze({
167
167
  ],
168
168
  requiresEnvironmentDerivedIds: false,
169
169
  });
170
+ const ruleTimingBizService = Object.freeze({
171
+ id: "rule-timing-bizservice",
172
+ title: "定时任务:定时调用业务方法(SCHEDULER-only 图 + BIZ_SERVICE,生产 106 条实证形状)",
173
+ aliases: ["定时调服务", "定时调接口", "定时调用方法", "BIZ_SERVICE", "定时调方法"],
174
+ status: "requires-prior-readback",
175
+ tool: "rule.save",
176
+ args: {
177
+ environment,
178
+ schemaCode: model,
179
+ ruleCode: "<customRuleCode e.g. nightly_ledger_rebuild>",
180
+ name: "每日台账重建",
181
+ nodeGraph: {
182
+ bizRuleType: 2,
183
+ // 与 rule-timing-task 的关键差异:**无动作节点**——方法挂在 schedulerSetting
184
+ // 上,不是 SCHEDULER 后面再接 UPDATE/BIZ_ACTION。生产 106 条「定时调服务」
185
+ // 全部是这个形状(Start → SCHEDULER → End)。
186
+ nodes: [
187
+ { nodeCode: "Start", nodeName: "开始", nodeType: "START" },
188
+ { nodeCode: "SCHEDULER", nodeName: "定时作业", nodeType: "SCHEDULER", isSystemNode: true, nodeCategory: "systemNode" },
189
+ { nodeCode: "End", nodeName: "结束", nodeType: "END" },
190
+ ],
191
+ routes: [
192
+ { preNode: "Start", postNode: "SCHEDULER", routeCondition: true },
193
+ { preNode: "SCHEDULER", postNode: "End", routeCondition: true },
194
+ ],
195
+ schedulerSetting: {
196
+ triggerTimeType: "SELECTED_TIME",
197
+ cycleType: "DAY",
198
+ executeType: "CYCLE",
199
+ execCycle: 1,
200
+ hour: 2,
201
+ minute: 0,
202
+ second: 0,
203
+ startTime: "<yyyy-MM-dd HH:mm:ss,未来时刻>",
204
+ endType: "NEVER",
205
+ // 定时调用业务集成方法:以方法为数据源,调度到点直接调用。
206
+ dataSourceType: "BIZ_SERVICE",
207
+ // methodCode 是**应用编码(appCode)**,不是方法 code——serviceCode+
208
+ // serviceMethodCode 定位方法,methodCode 定位应用。缺它:8.6 create 容忍、
209
+ // 8.1 update 回无明细 50000 且 enable 只翻 status 不翻 enabled
210
+ // (2026-11-03 issue A7/A8 实证;写前 gate 会直接拒绝缺失形态)。
211
+ methodMapping: {
212
+ schemaCode: model,
213
+ serviceCode: "<已注册服务 code>",
214
+ serviceMethodCode: "<方法 code>",
215
+ methodCode: "<应用编码 appCode>",
216
+ inputMappings: [
217
+ { serviceMethodParameterCode: "<参数 code,与方法声明逐字一致>", codeType: 0, bizCode: "<常量值>", required: false },
218
+ ],
219
+ },
220
+ latestVersion: true,
221
+ consolidation: false,
222
+ },
223
+ },
224
+ publish: true,
225
+ },
226
+ preconditions: [
227
+ "目标业务集成服务与方法**已注册且已发布**(service.list / service.get 核对)",
228
+ "`methodCode`(应用编码)已知——不确定时先 `rule.get --keys schedulerSetting` 一条同族生产定时规则对照 methodMapping 形状",
229
+ "本环境需 `cloudpivot.task.enabled=true`(否则平台不建调度任务)",
230
+ ],
231
+ conditionalRequirements: [
232
+ "**不要**套用 `rule-timing-task`(GET_LIST + UPDATE 动作节点)的形状——那是「定时改数据」,本配方是「定时调方法」;两者 schedulerSetting.dataSourceType 与节点图都不同。",
233
+ "`methodMapping.inputMappings[]` 的 `serviceMethodParameterCode` 必须与方法声明的参数 code 逐字一致(method.get 读参数面核对,readbackCaveat 出现时以 inputParametersFromList 为准);无参方法传 `[]`。",
234
+ "`latestVersion` 必须 true(false 走旧 builder → publish/update 50000,与 rule-timing-task 同坑)。",
235
+ "保存≠生效:`rule.save` 后必须 `rule.enable`;enable 回成功但 DB `enabled` 仍为 0 = 实体残缺(methodCode 缺失/options 空串),回 rule.save 修实体,不是重试 enable。",
236
+ ],
237
+ priorToolReferences: [
238
+ { tool: "service.list", args: { environment }, purpose: "确认服务已注册" },
239
+ { tool: "method.get", args: { environment, serviceCode: "<svc>", methodCode: "<method>" }, purpose: "读参数面拿 serviceMethodParameterCode(readbackCaveat.parametersUnreadable 出现时以 inputParametersFromList 为准)" },
240
+ { tool: "rule.get", args: { environment, schemaCode: model, ruleCode: "<同族生产规则>", keys: ["schedulerSetting"] }, purpose: "不确定 methodMapping 形状时对照生产实证形状" },
241
+ ],
242
+ dryRun: { supported: true, command: `omc call rule.save --env ${environment} --dry-run --args '<recipe.args>'`, notes: "dry-run 跑图校验 + BIZ_SERVICE 写前 gate(methodMapping 三键非空),不写不发布" },
243
+ expectedReadback: { readback: { published: true, enabled: false, next: "rule.enable;到点后以平台日志/方法侧效果核验,不认返回值" } },
244
+ refusalRecovery: [
245
+ { code: "rule-scheduler-bizservice-mapping", action: "dataSourceType:BIZ_SERVICE 必须带 methodMapping{serviceCode,serviceMethodCode,methodCode};methodCode 是应用编码(appCode)。对照 rule.get 的生产规则形状补齐" },
246
+ { code: "rule-name-invalid", action: "规则名仅支持中文/字母/数字、≤50 字符(平台设计器约束)" },
247
+ { code: "rule-graph-scheduler-setting-shape", action: "schedulerSetting 必须是 nodeGraph.schedulerSetting 上的对象,不是 SCHEDULER 节点字段" },
248
+ ...commonRecovery,
249
+ ],
250
+ requiresEnvironmentDerivedIds: true,
251
+ });
170
252
  const dataDuplicateFlag = Object.freeze({
171
253
  id: "data-duplicate-flag",
172
254
  title: "字段唯一性防重打标(8.6.26 实证唯一可靠路径):预置 Create 规则挂 BIZ_ACTION 自调二开 markDuplicates(规则图内 QUERY→UPDATE 打标会静默空转)",
@@ -328,6 +410,6 @@ const ruleBizactionInvokeService = Object.freeze({
328
410
  requiresEnvironmentDerivedIds: true,
329
411
  });
330
412
  export const BUSINESSRULE_METADATA = Object.freeze({
331
- "rule.save": Object.freeze({ recipes: [ruleGraph, ruleBranchGraph, ruleTimingTask, dataDuplicateFlag, ruleCrossModelCreate, ruleBizactionInvokeService], relatedTools: ["field.list", "rule.get", "rule.enable", "service.get"] }),
413
+ "rule.save": Object.freeze({ recipes: [ruleGraph, ruleBranchGraph, ruleTimingTask, ruleTimingBizService, dataDuplicateFlag, ruleCrossModelCreate, ruleBizactionInvokeService], relatedTools: ["field.list", "rule.get", "rule.enable", "service.get"] }),
332
414
  });
333
415
  //# sourceMappingURL=businessrule.js.map
@@ -838,6 +838,34 @@ export async function executeRuleSaveWrite(session, args) {
838
838
  // SCHEDULER rules keep the schedule on the rule row (schedulerSetting), not
839
839
  // on the node: normalize object → JSON string exactly like node/route.
840
840
  const schedulerInput = preparedGraph.schedulerSetting ?? preparedGraph.scheduler;
841
+ // A7 写前 gate(2026-11-03 issue,8.1.108 实测):dataSourceType:"BIZ_SERVICE" 的
842
+ // 调度必须携带 methodMapping{serviceCode,serviceMethodCode,methodCode}。缺
843
+ // methodCode(应用编码)时平台 create 容忍、update 回**无明细 50000**,且 enable
844
+ // 只翻 status 不翻 enabled(实体残缺装不起调度链)。在本地拒绝,不发到 wire。
845
+ const schedulerForGate = typeof schedulerInput === "string" ? parseJsonColumn(schedulerInput) : schedulerInput;
846
+ if (schedulerForGate !== null && typeof schedulerForGate === "object" && !Array.isArray(schedulerForGate)
847
+ && String(schedulerForGate.dataSourceType ?? "") === "BIZ_SERVICE") {
848
+ const mapping = schedulerForGate.methodMapping;
849
+ const missing = [];
850
+ if (mapping === null || typeof mapping !== "object" || Array.isArray(mapping)) {
851
+ missing.push("methodMapping{serviceCode,serviceMethodCode,methodCode}");
852
+ }
853
+ else {
854
+ for (const key of ["serviceCode", "serviceMethodCode", "methodCode"]) {
855
+ const value = mapping[key];
856
+ if (typeof value !== "string" || !value.trim())
857
+ missing.push(`methodMapping.${key}`);
858
+ }
859
+ }
860
+ if (missing.length > 0) {
861
+ throw new ToolRefusalError({
862
+ status: "refused",
863
+ blocker: "rule-scheduler-bizservice-mapping",
864
+ message: `schedulerSetting.dataSourceType=BIZ_SERVICE 但 ${missing.join("、")} 缺失(methodCode 是应用编码 appCode,不是方法 code——serviceCode+serviceMethodCode 定位方法,methodCode 定位应用)。缺 methodCode 的实体平台 create 容忍、update 回无明细 50000,且 enable 只翻 status 不翻 enabled。可先 rule.get 一条同族生产规则对照 methodMapping 形状。`,
865
+ missing,
866
+ });
867
+ }
868
+ }
841
869
  const schedulerSetting = schedulerInput === undefined || schedulerInput === null
842
870
  ? undefined
843
871
  : typeof schedulerInput === "string"
@@ -885,8 +913,40 @@ export async function executeRuleSaveWrite(session, args) {
885
913
  // Only CUSTOM rule codes take the caller's name. `existing` already carries
886
914
  // the platform name, so the spread preserves it.
887
915
  const isPresetRule = PLATFORM_TRIGGER_RULE_CODES.has(args.ruleCode);
916
+ // A5 写前 gate(2026-11-03 issue):平台设计器只接受 中文/字母/数字、≤50 字符的
917
+ // 规则名。违规名 create 能落库(平台不拦),但设计器打开即报「规则名称仅支持
918
+ // 输入中文、字母和数字」且无法编辑——产出一个 UI 不可维护的 landmine。
919
+ // 只约束自定义规则(预置触发行的 name 属平台,见上方 isPresetRule 分支)。
920
+ if (!isPresetRule && typeof args.name === "string" && !/^[\u4e00-\u9fa5A-Za-z0-9]{1,50}$/.test(args.name)) {
921
+ throw new ToolRefusalError({
922
+ status: "refused",
923
+ blocker: "rule-name-invalid",
924
+ message: `规则 name「${args.name.slice(0, 60)}」不符合平台约束:仅支持中文/字母/数字,长度 1-50(设计器校验;违规名虽能落库,但设计器无法再编辑该规则)。建议改为纯中文或字母数字命名。`,
925
+ });
926
+ }
927
+ // A8(2026-11-03 issue,8.1.108 实测):`options` / `extensionSetting` 缺席或空串
928
+ // 时平台落 "",8.1 族实体校验要求结构化的默认值——update 回无明细 50000。写前
929
+ // 归一:空串/缺席补平台默认结构(与设计器保存行为一致)。
930
+ const normalizeRuleJsonColumn = (value, fallback) => {
931
+ if (typeof value === "string" && value.trim())
932
+ return value;
933
+ if (value !== null && typeof value === "object")
934
+ return JSON.stringify(value);
935
+ return fallback;
936
+ };
888
937
  const performed = existing?.id
889
- ? await wireCall(session, { path: ENDPOINTS.businessRuleUpdate, method: "POST", body: { ...existing, ...(isPresetRule ? {} : { name: args.name }), node: nodeJson, route: routeJson, bizRuleType, ...schedulerField, enabled: existing.enabled === true, status: existing.status ?? "DISABLED" } })
938
+ ? await wireCall(session, { path: ENDPOINTS.businessRuleUpdate, method: "POST", body: {
939
+ ...existing,
940
+ ...(isPresetRule ? {} : { name: args.name }),
941
+ options: normalizeRuleJsonColumn(existing.options, '{"ruleVariable":[]}'),
942
+ extensionSetting: normalizeRuleJsonColumn(existing.extensionSetting, "{}"),
943
+ node: nodeJson,
944
+ route: routeJson,
945
+ bizRuleType,
946
+ ...schedulerField,
947
+ enabled: existing.enabled === true,
948
+ status: existing.status ?? "DISABLED",
949
+ } })
890
950
  : await wireCall(session, { path: ENDPOINTS.businessRuleCreate, method: "POST", body: { code: args.ruleCode, name: args.name, schemaCode: args.schemaCode, bizRuleType, node: nodeJson, route: routeJson, schedulerSetting: schedulerSetting ?? null, ...RULE_CREATE_BODY } });
891
951
  // Ticket 62: a bare save only writes the DRAFT working row — the designer
892
952
  // and the runtime read the PUBLISHED version, so "saved" ≠ "visible". An
package/dist/wire.js CHANGED
@@ -232,6 +232,32 @@ export async function wireCall(session, call) {
232
232
  return result;
233
233
  }
234
234
  /** List rows from the standard list envelope (array or {data:[]}). */
235
+ /**
236
+ * 版本族端点备选链(2026-11-03 issue A1,8.1.108 实测):部分端点只存在于
237
+ * 某个版本族——8.6 的 `runtime/business_rule/list_by_schemacode` 在 8.1 族不存在
238
+ * (404),8.1 用 `app/businessrule/list_by_schemacode`。按序尝试 candidates,
239
+ * **仅 HTTP 404(端点缺席)下探下一个**:401/500/transport/业务码拒绝原样抛出,
240
+ * 不吞真错误。全部 404 时重抛最后一个。
241
+ */
242
+ export async function wireCallWith404Fallback(session, candidates, onFallback) {
243
+ let lastError;
244
+ for (let index = 0; index < candidates.length; index++) {
245
+ const call = candidates[index];
246
+ try {
247
+ return await wireCall(session, call);
248
+ }
249
+ catch (error) {
250
+ const endpointAbsent = error instanceof WireError && error.kind === "http-status" && error.diagnostic?.httpStatus === 404;
251
+ if (!endpointAbsent)
252
+ throw error;
253
+ lastError = error;
254
+ const next = candidates[index + 1];
255
+ if (next && onFallback)
256
+ onFallback(call, next);
257
+ }
258
+ }
259
+ throw lastError;
260
+ }
235
261
  export function wireRows(value) {
236
262
  const rows = Array.isArray(value)
237
263
  ? value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@stalfh233/omc-fake-platform": "0.6.0",
55
+ "@stalfh233/omc-fake-platform": "0.6.3",
56
56
  "commander": "14.0.3",
57
- "omc-core": "0.6.0",
57
+ "omc-core": "0.6.3",
58
58
  "proper-lockfile": "4.1.2",
59
59
  "write-file-atomic": "6.0.0",
60
60
  "zod": "^3.23.8",