@stalfh233/omc-cli 0.6.2 → 0.6.4

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.
@@ -14,7 +14,11 @@ export async function admitGovernedWrite(ctx, request) {
14
14
  return settled(refusalFromCoverage(error));
15
15
  }
16
16
  const resolved = ctx.sessions.config().resolveEnvironment(request.environment);
17
- if (!environmentWritable(resolved.profile))
17
+ const writable = environmentWritable(resolved.profile);
18
+ // P2-5:只读环境默认拒绝;仅当调用方**显式**请求、且会话内证明这次用法只读,
19
+ // 才开一条窄缝。未请求/证明失败都回原来的 environment-write-disabled。
20
+ const escapeRequested = !writable && request.readOnlyEscape !== undefined && request.readOnlyEscape.requested(request.args ?? {});
21
+ if (!writable && !escapeRequested)
18
22
  return settled({
19
23
  status: "refused",
20
24
  blocker: "environment-write-disabled",
@@ -42,6 +46,26 @@ export async function admitGovernedWrite(ctx, request) {
42
46
  blocker: "version-family-evidence-missing",
43
47
  message: "writes fail closed when the CloudPivot version family cannot be established",
44
48
  });
49
+ // P2-5:只读放行的证明**放在版本门之后**——任何额外读回都要先过 fail-closed 的
50
+ // 版本证据门,保持「没有版本证据就不加新 wire 行为」的既有顺序。
51
+ let readOnlyEscape;
52
+ if (escapeRequested && request.readOnlyEscape !== undefined) {
53
+ let verdict;
54
+ try {
55
+ verdict = await request.readOnlyEscape.verify(session, request.args ?? {});
56
+ }
57
+ catch (error) {
58
+ return settled({
59
+ status: "refused",
60
+ blocker: "read-only-escape-unverified",
61
+ environment: resolved.name,
62
+ message: `只读放行的证明读回失败(${error instanceof Error ? error.message : String(error)});未执行任何试跑/查询。`,
63
+ });
64
+ }
65
+ if (!verdict.ok)
66
+ return settled({ ...verdict.refusal, environment: resolved.name });
67
+ readOnlyEscape = verdict.note;
68
+ }
45
69
  // Version family is advisory now: OMC is built on 8.6, but a deployment on a
46
70
  // different version (or product line, e.g. AI-Platform) is accepted in
47
71
  // compatibility mode. Missing evidence still fails closed above.
@@ -81,6 +105,7 @@ export async function admitGovernedWrite(ctx, request) {
81
105
  profile: resolved.profile,
82
106
  plan,
83
107
  ...(productionConfirmed ? { productionConfirmed: true } : {}),
108
+ ...(readOnlyEscape !== undefined ? { readOnlyEscape } : {}),
84
109
  },
85
110
  };
86
111
  }
@@ -181,7 +181,7 @@ export const B2_LANE_CONTRACT = Object.freeze({
181
181
  }),
182
182
  "service.table_sql": Object.freeze({
183
183
  scope: "readback",
184
- description: "表 SQL 模板发现:`/bizservice/method/get_table_column?serviceCode=&tableName=`。端点名虽为 column,实际返回**生成的 SQL 模板**(DataTableSqlModel{sql,sqlType ∈ SELECT/INSERT/UPDATE/DELETE/UPSERT},BizServiceFacadeImpl.getDataTableSql:889-910),不是列元数据;同样仅 DATA_TABLE 服务(501010)。纯只读。",
184
+ description: "表 SQL 模板发现:`/bizservice/method/get_table_column?serviceCode=&tableName=`。端点名虽为 column,实际返回**生成的 SQL 模板**(DataTableSqlModel{sql,sqlType ∈ SELECT/INSERT/UPDATE/DELETE/UPSERT},BizServiceFacadeImpl.getDataTableSql:889-910),不是列元数据;同样仅 DATA_TABLE 服务(501010)。纯只读。**只回模板、不执行**——平台没有自由 SQL 查询通道(要跑已注册的只读方法用 method.test,要查业务数据用 data.list 分片),别把本工具当查询入口(GitLab #18 P2-6)。",
185
185
  }),
186
186
  "service.delete": Object.freeze({
187
187
  scope: B2_SCOPE,
@@ -19,6 +19,82 @@ const B2_TOOLS = B2_LANE_CONTRACT.tools;
19
19
  function decodeJsonColumn(value) {
20
20
  return decodeSharedJsonColumn(value, JSON_COLUMN_NULL_COALESCE);
21
21
  }
22
+ /**
23
+ * 只读 SQL 判定用的结构视图:剥掉字符串字面量与注释,只留可执行的 SQL 骨架。
24
+ *
25
+ * 为什么需要:`SELECT note FROM ledger WHERE note = 'for update'` 是正常查询,
26
+ * 但直接对全文跑「FOR UPDATE」正则会误杀;反过来,若不剥字面量就放宽规则,
27
+ * `SELECT 1 INTO OUTFILE ...` 这类真副作用又会漏过。
28
+ *
29
+ * 保守约定:引号/注释**不配对**时返回 null(调用方据此拒绝)——不猜残缺 SQL 的
30
+ * 语义。支持 SQL 的两种转义(`''` 与 `\'`)。
31
+ */
32
+ function stripSqlLiteralsAndComments(sql) {
33
+ let out = "";
34
+ let index = 0;
35
+ while (index < sql.length) {
36
+ const char = sql[index];
37
+ const next = sql[index + 1];
38
+ if (char === "'" || char === '"' || char === "`") {
39
+ const quote = char;
40
+ index += 1;
41
+ let closed = false;
42
+ while (index < sql.length) {
43
+ const current = sql[index];
44
+ if (current === "\\") {
45
+ index += 2;
46
+ continue;
47
+ }
48
+ if (current === quote) {
49
+ if (quote !== "`" && sql[index + 1] === quote) {
50
+ index += 2;
51
+ continue;
52
+ } // '' 转义
53
+ index += 1;
54
+ closed = true;
55
+ break;
56
+ }
57
+ index += 1;
58
+ }
59
+ if (!closed)
60
+ return null;
61
+ out += " '' ";
62
+ continue;
63
+ }
64
+ if (char === "-" && next === "-" && (index + 2 >= sql.length || /\s/.test(sql[index + 2]))) {
65
+ // SQL/MySQL 的 `--` 注释要求后面跟空白;不跟空白时是算术(`1--2`),
66
+ // 若当注释剥掉会把其后的 `INTO OUTFILE` 一起吞成注释 → 绕过。
67
+ const end = sql.indexOf("\n", index);
68
+ if (end < 0)
69
+ return out;
70
+ index = end;
71
+ continue;
72
+ }
73
+ if (char === "#") {
74
+ // MySQL 的 `#` 注释(无需空白)。
75
+ const end = sql.indexOf("\n", index);
76
+ if (end < 0)
77
+ return out;
78
+ index = end;
79
+ continue;
80
+ }
81
+ if (char === "/" && next === "*") {
82
+ // MySQL 可执行注释 `/*!50000 ... */` 里的语句**会执行**——不能当注释剥掉,
83
+ // 直接判定为无法静态证明只读。
84
+ if (sql[index + 2] === "!")
85
+ return null;
86
+ const end = sql.indexOf("*/", index + 2);
87
+ if (end < 0)
88
+ return null; // 未闭合注释:拒绝
89
+ index = end + 2;
90
+ out += " ";
91
+ continue;
92
+ }
93
+ out += char;
94
+ index += 1;
95
+ }
96
+ return out;
97
+ }
22
98
  function adapterCodeOf(serviceRow) {
23
99
  return typeof serviceRow?.adapterCode === "string" ? serviceRow.adapterCode : "";
24
100
  }
@@ -365,11 +441,37 @@ export function registerB2Lane(tools) {
365
441
  const row = fetched.data;
366
442
  if (!row)
367
443
  return { serviceCode: args.serviceCode, methodCode: args.methodCode, present: false, absent: true, method: null };
444
+ const adapterCode = adapterCodeOf(serviceRow);
445
+ const inputParameters = normalizeParameterFaceReadback(adapterCode, decodeJsonColumn(row.inputParametersJson));
446
+ const outputParameters = normalizeParameterFaceReadback(adapterCode, decodeJsonColumn(row.outputParametersJson));
447
+ // A2 双面交叉校验(2026-11-03 issue,8.1.108 实测):get 面(按 id)可能把
448
+ // DB 实有的参数面读成 "[]"(同服务短参数正常、637 字节 3 参数读空——长度
449
+ // 相关的平台读缺陷)。list 面同列非空而 get 面为空时,标记
450
+ // parametersUnreadable 并附 list 面参数,禁止下游按「无参数」处理(曾导致
451
+ // 把配置正确的入参误判为缺失并重配)。
452
+ const crossCheck = (column, face, fromListKey) => {
453
+ const listFace = decodeJsonColumn(listed[column]);
454
+ const listCount = Array.isArray(listFace) ? listFace.length : 0;
455
+ if (listCount === 0 || face.length > 0)
456
+ return null;
457
+ return {
458
+ parametersUnreadable: true,
459
+ listFaceCount: listCount,
460
+ getFaceCount: face.length,
461
+ hint: `${column} 的 get 面(按 id)读回为空,但 list 面实有 ${listCount} 个参数——平台读缺陷(8.1.108 实测),勿按「无参数」处理;${fromListKey} 是 list 面读回的参数,以其为准复核`,
462
+ [fromListKey]: normalizeParameterFaceReadback(adapterCode, listFace),
463
+ };
464
+ };
465
+ const inputCross = crossCheck("inputParametersJson", Array.isArray(inputParameters) ? inputParameters : [], "inputParametersFromList");
466
+ const outputCross = crossCheck("outputParametersJson", Array.isArray(outputParameters) ? outputParameters : [], "outputParametersFromList");
368
467
  const method = {
369
468
  ...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)),
469
+ configJson: maskServiceConfigSecrets(normalizeMethodConfigReadback(adapterCode, decodeJsonColumn(row.configJson))),
470
+ inputParameters,
471
+ outputParameters,
472
+ ...(inputCross || outputCross
473
+ ? { readbackCaveat: { ...(inputCross ?? {}), ...(outputCross ?? {}) } }
474
+ : {}),
373
475
  };
374
476
  return { serviceCode: args.serviceCode, methodCode: args.methodCode, present: true, method };
375
477
  }),
@@ -1268,10 +1370,109 @@ export function registerB2Lane(tools) {
1268
1370
  const TEST_EVIDENCED_ADAPTERS = new Set(["RESTFUL", "DATA_SQL", "DATA_TABLE", "SOAP", "INTERNAL"]);
1269
1371
  /** Adapters whose method configJson must be non-null to be executable (SOAP methods are WSDL-discovered and carry none). */
1270
1372
  const TEST_CONFIG_REQUIRED_ADAPTERS = new Set(["RESTFUL", "DATA_SQL", "DATA_TABLE", "SAP", "KAFKA", "INTERNAL"]);
1373
+ /** 只读环境放行判定:只认「显式传参 + DATA_SQL + 严格 SELECT-only」。 */
1374
+ const methodTestReadOnlyEscape = {
1375
+ requested: (args) => args.readOnlyOnReadOnlyEnv === true,
1376
+ verify: async (session, args) => {
1377
+ const serviceCode = String(args.serviceCode ?? "");
1378
+ const methodCode = String(args.methodCode ?? "");
1379
+ const refusal = (blocker, message, extra = {}) => ({
1380
+ ok: false,
1381
+ refusal: { status: "refused", blocker, serviceCode, methodCode, message, ...extra },
1382
+ });
1383
+ let serviceRow;
1384
+ try {
1385
+ serviceRow = (await wireCall(session, { path: ENDPOINTS.bizServiceGetByCode, query: { serviceCode } })).data;
1386
+ }
1387
+ catch (error) {
1388
+ return refusal("read-only-escape-unverified", `只读放行需要先证明方法确实是只读 SELECT,但服务配置读回失败(${error instanceof Error ? error.message : String(error)})——零试跑调用。`);
1389
+ }
1390
+ if (serviceRow === null)
1391
+ return refusal("wire-business", `业务服务不存在:${serviceCode}`);
1392
+ const adapterCode = adapterCodeOf(serviceRow);
1393
+ if (adapterCode !== "DATA_SQL")
1394
+ return refusal("read-only-escape-adapter-refused", `readOnlyOnReadOnlyEnv 只对 DATA_SQL 适配器生效(本次 ${adapterCode || "(空)"}):RESTFUL/SOAP/INTERNAL 等会真实打外部系统(可能 INSERT/UPDATE),无法静态证明只读,只读环境一律拒绝。`, { adapterCode });
1395
+ let rows;
1396
+ try {
1397
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.bizServiceMethodList, query: { serviceCode, filterFlag: "false" } })).data);
1398
+ }
1399
+ catch (error) {
1400
+ return refusal("read-only-escape-unverified", `方法面读回失败(${error instanceof Error ? error.message : String(error)})——零试跑调用。`);
1401
+ }
1402
+ const method = rows.find((row) => row.code === methodCode);
1403
+ if (!method)
1404
+ return refusal("wire-business", `业务方法不存在:${serviceCode}/${methodCode}`);
1405
+ // 平台把方法 configJson 以**明文 JSON**落库(controller 解密后存明文;
1406
+ // 回读面同样是明文)。个别部署/中间层可能回密文,所以两种都认:先当明文
1407
+ // JSON 解析,失败再按 AES 解密——两条都失败就拒绝(不能证明只读)。
1408
+ const rawConfig = typeof method.configJson === "string" ? method.configJson : "";
1409
+ if (!rawConfig)
1410
+ return refusal("bizservice-test-shell", `方法 ${methodCode} 是注册壳(无 configJson)——不可执行,只读放行不适用。`);
1411
+ let parsedConfig;
1412
+ try {
1413
+ parsedConfig = JSON.parse(rawConfig);
1414
+ }
1415
+ catch {
1416
+ try {
1417
+ parsedConfig = JSON.parse((await omcCore()).decryptBizServiceMethodConfigJson(rawConfig));
1418
+ }
1419
+ catch (error) {
1420
+ return refusal("read-only-escape-unverified", `configJson 既不是明文 JSON 也无法解密(${error instanceof Error ? error.message : String(error)})——不能证明只读,零试跑调用。`);
1421
+ }
1422
+ }
1423
+ const sql = typeof parsedConfig.sql === "string" ? parsedConfig.sql : "";
1424
+ const trimmed = sql.trim().replace(/^\(+/, "").trim();
1425
+ // 严格 SELECT-only:`;` 多语句、WITH 后接 DML 一律不放行(宁可误杀)。
1426
+ // 审查补强(2026-11-04):`/^select/` 远不等于只读——下面这些都是真实
1427
+ // 副作用写,却被首词 select 掩护:
1428
+ // SELECT … INTO OUTFILE/DUMPFILE '/tmp/x' → 落盘写文件
1429
+ // SELECT … INTO @v → 会话变量(配合后续可用)
1430
+ // SELECT … FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE → 行锁
1431
+ // SELECT 1\nDELETE FROM t → 换行分隔的第二条语句(无分号)
1432
+ // 只读环境上放行这些等于给写副作用开口子,所以一律拒绝。
1433
+ // 先剥掉字符串字面量与注释再判(否则 `WHERE note = 'for update'` 被误杀);
1434
+ // 引号/注释不配对就直接拒绝——那段 SQL 本身就有问题,不猜。
1435
+ const structural = stripSqlLiteralsAndComments(sql);
1436
+ if (structural === null)
1437
+ return refusal("read-only-escape-not-select", `readOnlyOnReadOnlyEnv 需要能静态判定的 SQL:方法 ${methodCode} 的 SQL 字符串/注释引号不配对,无法证明只读(零试跑调用)。`, { sqlPreview: sql.slice(0, 120) });
1438
+ const dangerousClauses = [
1439
+ { pattern: /\binto\s+(outfile|dumpfile)\b/i, label: "INTO OUTFILE/DUMPFILE(写文件)" },
1440
+ { pattern: /\binto\s+@\w+/i, label: "INTO @变量(会话变量)" },
1441
+ { pattern: /\bfor\s+(update|share)\b/i, label: "FOR UPDATE/SHARE(行锁)" },
1442
+ { pattern: /\block\s+in\s+share\s+mode\b/i, label: "LOCK IN SHARE MODE(行锁)" },
1443
+ // 不再只盯「换行后的第二条语句」:剥离注释后 `SELECT 1 /*x*/ DELETE …`
1444
+ // 会变成同一行,只查换行前缀就漏。改成全文任何位置出现写关键字即拒绝
1445
+ // (fail-closed;正常 SELECT 不会出现这些词,列名如 update_log 有下划线
1446
+ // 不算词边界,不会被误杀)。
1447
+ { pattern: /\b(insert|update|delete|drop|alter|truncate|create|replace|call|exec|execute|merge|grant|revoke)\b/i, label: "写/DDL 关键字(非纯 SELECT)" },
1448
+ ];
1449
+ const dangerous = dangerousClauses.find((entry) => entry.pattern.test(structural));
1450
+ const looksSelectOnly = /^select\b/i.test(trimmed) && !/[;]/.test(structural) && !/^with\b/i.test(trimmed) && dangerous === undefined;
1451
+ if (!looksSelectOnly)
1452
+ return refusal("read-only-escape-not-select", dangerous !== undefined
1453
+ ? `readOnlyOnReadOnlyEnv 只放行**纯读取**的 SELECT(方法 ${methodCode} 的 SQL 含 ${dangerous.label})——这在只读环境上仍然有副作用,请在可写环境执行。`
1454
+ : `readOnlyOnReadOnlyEnv 只放行单条 SELECT(方法 ${methodCode} 的 SQL 不是严格 SELECT-only:禁止 ';' 多语句、WITH…DML、非 SELECT 语句)。要跑写库/DDL 方法请在可写环境执行。`, { sqlPreview: sql.slice(0, 120), ...(dangerous !== undefined ? { rejectedClause: dangerous.label } : {}) });
1455
+ return {
1456
+ ok: true,
1457
+ note: {
1458
+ reason: "read-only-select-on-read-only-environment",
1459
+ adapterCode,
1460
+ methodCode,
1461
+ sqlPreview: sql.slice(0, 120),
1462
+ note: "只读环境 + 显式 readOnlyOnReadOnlyEnv + DATA_SQL 严格 SELECT-only:本次试跑已放行;它仍会真调数据库(SELECT)并记集成日志,不写配置行。",
1463
+ },
1464
+ };
1465
+ },
1466
+ };
1271
1467
  writeTool(tools, "method.test", B2_LANE, B2_TOOLS["method.test"].scope, B2_TOOLS["method.test"].description, {
1272
1468
  serviceCode: z.string().min(1),
1273
1469
  methodCode: z.string().min(1),
1274
1470
  testInputParametersMap: z.record(z.string(), z.unknown()).describe("试跑入参:参数 code → JSON 值(BizServiceMethodVO Map<String,Object>;按方法声明参数面校验)"),
1471
+ // P2-5(GitLab #18):method.test 是治理写(会真调外部系统),只读环境下默认
1472
+ // 拒绝——但一条纯 SELECT 的 DATA_SQL 方法没有任何副作用,此前却必须先把
1473
+ // 生产环境改成 writable 才能读数据(安全语义倒置)。显式传本参数 + 工具
1474
+ // 用真实配置证明「DATA_SQL 且严格 SELECT-only」才放行,其余一律原写门。
1475
+ readOnlyOnReadOnlyEnv: z.boolean().optional().describe("只读环境(writable=false)里显式放行本次试跑:仅当服务是 DATA_SQL 且方法 SQL 是单条 SELECT 时生效;不传=按写门拒绝(默认)。仍会真执行 SELECT 并记集成日志,不写配置行"),
1275
1476
  }, async (session, args) => {
1276
1477
  // Ticket 107 — a REAL one-shot execution of the configured method
1277
1478
  // (BizServiceMethodController.java:398-412 → BizServiceFacadeImpl:426-464
@@ -1372,6 +1573,6 @@ export function registerB2Lane(tools) {
1372
1573
  ...(externalSuccess ? {} : { externalFailure }),
1373
1574
  },
1374
1575
  };
1375
- });
1576
+ }, undefined, undefined, undefined, methodTestReadOnlyEscape);
1376
1577
  }
1377
1578
  //# sourceMappingURL=b2-registration.js.map
@@ -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, {
@@ -144,21 +144,41 @@ export function registerFormLane(tools) {
144
144
  ...(wanted ? { draftControls: pick(sheet.draftAttributesJson), publishedControls: pick(sheet.publishedAttributesJson) } : {}),
145
145
  };
146
146
  }, {
147
+ // T1(GitLab #17,2026-11-04):`full` 此前没传,于是 form.get --full 永远
148
+ // 整包内联(实测 94.9KB 只为 grep 一个串)——与 data/workflow 的 8KB 读门
149
+ // 不一致,且外置态的根键随来源漂移。传入后大包走 fullOmitted + artifact,
150
+ // 小包行为不变(≤8KB 仍原样内联)。
151
+ full: args.full === true,
147
152
  // 2026-11-01 读门 2048:controls=true 是**显式**取控件定义的请求——compact
148
153
  // 保留定义映射与按钮/options(--full 的语义面),其余大数组归码表/计数。
154
+ // T1:六份布局 JSON 是大**字符串**(不是数组/对象),旧 compact 的 else 分支
155
+ // 原样保留 → 外置了也没省体积。超过 4KB 的单串改为 `*Omitted` + 字节数,
156
+ // 正文用 `artifact get <detailArtifactId> --jq draftViewJson` 定向取回。
149
157
  compact: (result) => {
150
158
  const KEEP = new Set(["draftControls", "publishedControls", "customButtonJson", "options", "formButtons", "formButtonCodes"]);
151
159
  const out = {};
160
+ const omitted = [];
152
161
  for (const [key, value] of Object.entries(result)) {
153
162
  if (KEEP.has(key))
154
163
  out[key] = value;
164
+ else if (typeof value === "string" && Buffer.byteLength(value, "utf8") > 4096) {
165
+ out[`${key}Omitted`] = true;
166
+ out[`${key}Bytes`] = Buffer.byteLength(value, "utf8");
167
+ omitted.push(key);
168
+ }
155
169
  else if (Array.isArray(value))
156
170
  out[`${key}Codes`] = value.map((entry) => (entry && typeof entry === "object" ? String(entry.code ?? entry.propertyCode ?? "") : String(entry))).filter((entry) => entry.length > 0), out[`${key}Count`] = value.length;
171
+ // 大**对象**也要降载:审查 2026-11-04 指出旧 else 分支把
172
+ // htmlRenderCompat 这类非 KEEP 对象整包内联,抵消了 T1 的省体积目标。
157
173
  else if (value !== null && typeof value === "object")
158
174
  out[`${key}Count`] = Object.keys(value).length;
159
175
  else
160
176
  out[key] = value;
161
177
  }
178
+ // 有任一布局被外置时给一行可执行取回路径,避免把 Omitted 面误读成
179
+ // 「这个表单没有布局」。
180
+ if (omitted.length > 0)
181
+ out.layoutHint = `这些布局正文已外置(${omitted.join("、")}):用 \`omc artifact get <detailArtifactId> --jq <key>\` 取原文,例如 --jq draftViewJson。**不要**把 Omitted 面当空布局回写 form.draft(fromReadback 需要 artifact 里的原文)。`;
162
182
  return out;
163
183
  },
164
184
  }),
@@ -9,7 +9,7 @@ import { writeSummary } from "../human-summary.js";
9
9
  import { capabilityArg, environmentArg } from "../args.js";
10
10
  import { governedRead, withoutReadbackCache } from "../read.js";
11
11
  import { parseToolInput } from "../tool-types.js";
12
- import { applicationResolutionOf, applicationScopeHint, flattenTreeRows, resolveApplicationCode, resolveApplicationScope } from "../support.js";
12
+ import { applicationResolutionOf, applicationScopeHint, applicationScopeNotice, flattenTreeRows, resolveApplicationCode, resolveApplicationScope } from "../support.js";
13
13
  import { writeTool } from "../write.js";
14
14
  import { registerPackageTool } from "../package-tool.js";
15
15
  import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
@@ -221,6 +221,7 @@ export function registerModelLane(tools) {
221
221
  totalBeforeFilter: all.length,
222
222
  filterApplied: needle !== undefined && needle !== "",
223
223
  ...(needle ? { filter: needle } : {}),
224
+ scopeHint: applicationScopeNotice(scope),
224
225
  models,
225
226
  ...(models.length === 0 ? { hint: applicationScopeHint(scope, "模型") } : {}),
226
227
  };
@@ -275,14 +276,28 @@ export function registerModelLane(tools) {
275
276
  // parallel (bounded) instead of paying six sequential round-trips. Only
276
277
  // workflow.get_published stays chained (it needs workflowCode from the
277
278
  // draft face). Output ordering is unchanged (results land by index).
278
- const [fieldFace, formFace, listviewFace, ruleFace, dataruleFace, draftFace] = await mapBounded([
279
+ // T4(GitLab #17,2026-11-04):业务方永远按中文菜单名提问,而本面此前不
280
+ // 回显示名——只能靠 listview.list 反推(未配视图的模型无路可查)。应用树
281
+ // 节点本来就带 name,与六面同一批并发读,零额外顺序往返。
282
+ const treeFace = async () => {
283
+ try {
284
+ return { node: (await resolveModelTreeNode(session, schemaCode)).node };
285
+ }
286
+ catch {
287
+ return undefined;
288
+ }
289
+ };
290
+ const [fieldFace, formFace, listviewFace, ruleFace, dataruleFace, draftFace, treeRead] = await mapBounded([
279
291
  () => face("field.list", { schemaCode, published: true, ...(all ? { all: true } : {}) }, faceErrcodes),
280
292
  () => face("form.get", { schemaCode }),
281
293
  () => face("listview.get", { schemaCode }),
282
294
  () => face("rule.list", { schemaCode }),
283
295
  () => face("datarule.list", { schemaCode }),
284
296
  () => face("workflow.get_draft", { schemaCode }),
285
- ], 6, (task) => task());
297
+ () => treeFace(),
298
+ ], 7, (task) => task());
299
+ const treeNode = treeRead?.node;
300
+ const modelName = typeof treeNode?.name === "string" && treeNode.name ? treeNode.name : typeof treeNode?.text === "string" && treeNode.text ? treeNode.text : undefined;
286
301
  const fieldRows = Array.isArray(fieldFace?.fields) ? fieldFace.fields : [];
287
302
  const fields = fieldRows
288
303
  .filter((field) => all || !SYSTEM_FIELD_CODES.has(String(field.code)))
@@ -353,6 +368,7 @@ export function registerModelLane(tools) {
353
368
  const modelPresent = absent ? false : fieldFace !== undefined && !fieldRefused ? true : undefined;
354
369
  return {
355
370
  schemaCode,
371
+ ...(modelName !== undefined ? { name: modelName } : {}),
356
372
  ...(modelPresent !== undefined ? { modelPresent } : {}),
357
373
  ...(modelPresent === false
358
374
  ? {
@@ -367,7 +383,7 @@ export function registerModelLane(tools) {
367
383
  rulesCustom: rules.filter((rule) => rule.defaultRule !== true).map((rule) => rule.code),
368
384
  datarules,
369
385
  workflow,
370
- hint: "model.inspect 聚合设计时六面(fields/form/listview/rules/datarules/workflow,摘要)。modelPresent 是存在性判据(field 面 300004=false);字段 options 细节用 field.list --code;规则图用 rule.get schemaCode+ruleCode;运行数据用 data.list(不在此面)。",
386
+ hint: "model.inspect 聚合设计时六面(fields/form/listview/rules/datarules/workflow,摘要);name 是模型显示名(业务方的中文菜单名),与 schemaCode 一一对应,不必再用 listview.list 反推。modelPresent 是存在性判据(field 面 300004=false);字段 options 细节用 field.list --code;规则图用 rule.get schemaCode+ruleCode;运行数据用 data.list(不在此面)。",
371
387
  };
372
388
  };
373
389
  const one = await inspectOne(codes[0]);
@@ -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 } };