@stalfh233/omc-cli 0.4.71 → 0.4.73

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.
package/dist/artifacts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  export const CLI_DEFAULT_OUTPUT_BYTES = 64 * 1024;
5
5
  export const CLI_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
@@ -13,8 +13,30 @@ function ensureRoot(root) {
13
13
  mkdirSync(resolved, { recursive: true, mode: 0o700 });
14
14
  return resolved;
15
15
  }
16
+ const PRUNE_STAMP = ".prune-stamp";
17
+ const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
+ const PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
19
+ /**
20
+ * Opportunistic hygiene: at most once per day, drop artifacts untouched for a
21
+ * week and enforce the size budget. Hooked into createArtifact so a long-running
22
+ * project never needs a manual sweep; the stamp keeps the scan off the hot path
23
+ * (one readdir per day, not per call).
24
+ */
25
+ function maybeAutoPrune(resolvedRoot) {
26
+ const stampPath = join(resolvedRoot, PRUNE_STAMP);
27
+ try {
28
+ if (existsSync(stampPath) && Date.now() - statSync(stampPath).mtimeMs < PRUNE_INTERVAL_MS)
29
+ return;
30
+ pruneArtifacts(resolvedRoot, { olderThanMs: PRUNE_AGE_MS });
31
+ writeFileSync(stampPath, `${new Date().toISOString()}\n`, { mode: 0o600 });
32
+ }
33
+ catch {
34
+ // Hygiene is best-effort: never fail a write because cleanup did.
35
+ }
36
+ }
16
37
  export function createArtifact(root, content) {
17
38
  const resolvedRoot = ensureRoot(root);
39
+ maybeAutoPrune(resolvedRoot);
18
40
  const artifactId = randomUUID();
19
41
  const path = artifactPath(resolvedRoot, artifactId);
20
42
  const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : content;
@@ -116,4 +138,57 @@ export function assertSafeInputPath(root, candidate, label) {
116
138
  }
117
139
  return normalized;
118
140
  }
141
+ export function pruneArtifacts(root, options = {}) {
142
+ const resolvedRoot = resolve(root);
143
+ if (!existsSync(resolvedRoot))
144
+ return { removed: 0, freedBytes: 0, kept: 0, keptBytes: 0 };
145
+ const now = options.now ?? Date.now();
146
+ const olderThanMs = options.olderThanMs;
147
+ const maxBytes = options.maxBytes ?? 256 * 1024 * 1024;
148
+ const entries = [];
149
+ let keptBytes = 0;
150
+ let removed = 0;
151
+ let freedBytes = 0;
152
+ const removePair = (path, size) => {
153
+ try {
154
+ rmSync(path, { force: true });
155
+ rmSync(`${path}.sha256`, { force: true });
156
+ removed += 1;
157
+ freedBytes += size;
158
+ }
159
+ catch { /* best effort */ }
160
+ };
161
+ for (const name of readdirSync(resolvedRoot)) {
162
+ if (!/^[a-f0-9-]{36}\.artifact$/u.test(name))
163
+ continue;
164
+ const path = join(resolvedRoot, name);
165
+ let stat;
166
+ try {
167
+ stat = lstatSync(path);
168
+ }
169
+ catch {
170
+ continue;
171
+ }
172
+ if (stat.isSymbolicLink() || !stat.isFile())
173
+ continue;
174
+ if (olderThanMs !== undefined && now - stat.mtimeMs > olderThanMs) {
175
+ removePair(path, stat.size);
176
+ continue;
177
+ }
178
+ entries.push({ path, size: stat.size, mtimeMs: stat.mtimeMs });
179
+ keptBytes += stat.size;
180
+ }
181
+ // Budget sweep: oldest first until the store fits.
182
+ if (keptBytes > maxBytes) {
183
+ entries.sort((left, right) => left.mtimeMs - right.mtimeMs);
184
+ for (const entry of entries) {
185
+ if (keptBytes <= maxBytes)
186
+ break;
187
+ removePair(entry.path, entry.size);
188
+ keptBytes -= entry.size;
189
+ }
190
+ }
191
+ const kept = entries.filter((entry) => existsSync(entry.path)).length;
192
+ return { removed, freedBytes, kept, keptBytes };
193
+ }
119
194
  //# sourceMappingURL=artifacts.js.map
@@ -76,6 +76,11 @@ const GOLDEN_CASES = [
76
76
  async run(harness) {
77
77
  await call(harness, "model.create", { name: "金轨迹回填源", code: "omc_mcp_gd2" });
78
78
  await call(harness, "model.create", { name: "金轨迹回填目标", code: "omc_mcp_gp" });
79
+ // The platform's rule self-check resolves UPDATE targets against the
80
+ // TARGET model's PUBLISHED fields; an unseeded target would 550018 here
81
+ // and never exercise the DYNAMIC wire this case exists to pin.
82
+ await call(harness, "field.create", { schemaCode: "omc_mcp_gp", code: "latestPeriod", name: "最近申报期", propertyType: "SHORT_TEXT" });
83
+ await call(harness, "field.publish", { schemaCode: "omc_mcp_gp" });
79
84
  harness.fake.seedDataRow("omc_mcp_gp", { latestPeriod: "" }, "omc_mcp_gp_row");
80
85
  await call(harness, "rule.save", {
81
86
  schemaCode: "omc_mcp_gd2",
@@ -107,6 +112,9 @@ const GOLDEN_CASES = [
107
112
  async run(harness) {
108
113
  await call(harness, "model.create", { name: "金轨迹固定值源", code: "omc_mcp_gf2" });
109
114
  await call(harness, "model.create", { name: "金轨迹固定值目标", code: "omc_mcp_gf" });
115
+ await call(harness, "field.create", { schemaCode: "omc_mcp_gf", code: "state", name: "状态", propertyType: "SHORT_TEXT" });
116
+ await call(harness, "field.create", { schemaCode: "omc_mcp_gf", code: "count", name: "次数", propertyType: "NUMERICAL", options: { scale: 0 } });
117
+ await call(harness, "field.publish", { schemaCode: "omc_mcp_gf" });
110
118
  harness.fake.seedDataRow("omc_mcp_gf", { state: "old", count: 0 }, "omc_mcp_gf_row");
111
119
  await call(harness, "rule.save", {
112
120
  schemaCode: "omc_mcp_gf2",
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, requestId } fr
16
16
  import { configureOutput, humanSummary, stripOutputFlags, writeEnvelope } from "./cli-output.js";
17
17
  import { beginUsage, completeUsage, recordUsageOutput, usageReport } from "./usage-log.js";
18
18
  import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
19
- import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath } from "./artifacts.js";
19
+ import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath, pruneArtifacts } from "./artifacts.js";
20
20
  import { resolve } from "node:path";
21
21
  import { buildToolRegistry } from "./tools.js";
22
22
  import { acquireWriteLock } from "./write-lock.js";
@@ -437,6 +437,22 @@ function projectJsonPath(value, path) {
437
437
  }
438
438
  return { missed: false, value: current };
439
439
  }
440
+ function parseDurationMs(text) {
441
+ const match = /^\s*(\d+)\s*(ms|s|m|h|d|w)?\s*$/u.exec(text);
442
+ if (!match)
443
+ return undefined;
444
+ const amount = Number(match[1]);
445
+ const unit = match[2] ?? "d";
446
+ const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : unit === "d" ? 86_400_000 : 604_800_000;
447
+ return amount * factor;
448
+ }
449
+ function formatBytes(bytes) {
450
+ if (bytes < 1024)
451
+ return `${bytes}B`;
452
+ if (bytes < 1024 * 1024)
453
+ return `${(bytes / 1024).toFixed(1)}KB`;
454
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
455
+ }
440
456
  async function cmdArtifactGet(id, transport, options = {}) {
441
457
  try {
442
458
  const artifact = readArtifact(artifactRoot(), id);
@@ -1071,7 +1087,34 @@ async function main() {
1071
1087
  rollbackCommand.command("apply <ref>").option("--env <environment>").option("--environment <environment>").option("--confirm-production").action(async (ref, options) => {
1072
1088
  commandExitCode = await withTransport(options, "rollback apply", (transport) => cmdRollbackApply(ref, { environment: options.env ?? options.environment, confirmProduction: options.confirmProduction === true }, transport));
1073
1089
  });
1074
- command.command("artifact").command("get <id>").option("--jq <path>").option("--path-only").option("--summary").option("--keys [a,b]").option("--max-output-bytes <bytes>").action(async (id, options) => {
1090
+ const artifactCommand = command.command("artifact");
1091
+ artifactCommand.command("prune").option("--older-than <duration>", "删除超过该时长的读回产物(如 7d/12h/30m)").option("--max-bytes <bytes>", "产物目录预算;超出时按最旧优先删除").option("--json").action((options) => {
1092
+ // Machine-global store: oversized readbacks pile up (504 files / 3.4 MB
1093
+ // after one sandbox round). Pruning is deterministic and safe to run at
1094
+ // any time — it only removes this store's own artifact files.
1095
+ const olderThanMs = options.olderThan === undefined ? undefined : parseDurationMs(options.olderThan);
1096
+ if (options.olderThan !== undefined && olderThanMs === undefined) {
1097
+ console.error("用法:omc artifact prune [--older-than 7d] [--max-bytes 268435456] [--json]");
1098
+ commandExitCode = CLI_EXIT_CODES.invalidInput;
1099
+ return;
1100
+ }
1101
+ const maxBytes = options.maxBytes === undefined ? undefined : Number(options.maxBytes);
1102
+ if (options.maxBytes !== undefined && (!Number.isFinite(maxBytes) || Number(maxBytes) < 0)) {
1103
+ console.error("--max-bytes 必须是非负整数");
1104
+ commandExitCode = CLI_EXIT_CODES.invalidInput;
1105
+ return;
1106
+ }
1107
+ const result = pruneArtifacts(artifactRoot(), {
1108
+ ...(olderThanMs !== undefined ? { olderThanMs } : {}),
1109
+ ...(maxBytes !== undefined ? { maxBytes } : {}),
1110
+ });
1111
+ if (options.json)
1112
+ process.stdout.write(JSON.stringify({ ok: true, ...result }, null, 2) + "\n");
1113
+ else
1114
+ console.error(`已清理 ${result.removed} 个读回产物,释放 ${formatBytes(result.freedBytes)};保留 ${result.kept} 个(${formatBytes(result.keptBytes)})`);
1115
+ commandExitCode = CLI_EXIT_CODES.ok;
1116
+ });
1117
+ artifactCommand.command("get <id>").option("--jq <path>").option("--path-only").option("--summary").option("--keys [a,b]").option("--max-output-bytes <bytes>").action(async (id, options) => {
1075
1118
  commandExitCode = await withTransport(options, request, (transport) => cmdArtifactGet(id, transport, { ...(options.jq ? { jq: options.jq } : {}), pathOnly: options.pathOnly === true, summary: options.summary === true || options.keys === true, ...(typeof options.keys === "string" ? { keys: options.keys } : {}) }));
1076
1119
  });
1077
1120
  command.command("bench").option("--live-sample").option("--env <environment>").action(async (options) => { commandExitCode = await cmdBench([...(options.liveSample ? ["--live-sample"] : []), ...(options.env ? ["--env", options.env] : [])]); });
@@ -672,6 +672,59 @@ function refuseFieldOptions(reason, message) {
672
672
  };
673
673
  throw new ToolRefusalError({ status: "refused", blocker: "capability-out-of-coverage", citation });
674
674
  }
675
+ /**
676
+ * Aggregation query conditions are deserialized by Jackson into Java enums that
677
+ * are FIXED-ORDINAL (`BizPropertyType`, `OperatorType`): both must be written as
678
+ * their INTEGER code. A name (“NUMERICAL”/“GTEQ”) makes bizproperty/publish
679
+ * answer 50000 with `InvalidFormatException: Cannot deserialize value of type
680
+ * int from String` (live 2026-10-22, 1.1.25 scratch model). The portal UI writes
681
+ * ints, so accept the readable names and normalize them to the wire code —
682
+ * exactly the kind of adapter this lane already performs for `name_i18n`.
683
+ */
684
+ const AGGREGATION_OPERATOR_CODES = Object.freeze({
685
+ EQ: 1, NEQ: 2, GT: 3, IT: 4, GTEQ: 5, ITEQ: 6,
686
+ CONTAINS: 7, NOT_CONTAINS: 8, IS_NULL: 9, IS_NOT_NULL: 10,
687
+ OF: 11, NOT_OF: 12, HAVE: 13, NOT_HAVE: 14,
688
+ IN: 15, NOT_IN: 16, BETWEEN: 17, NOT_BETWEEN: 18,
689
+ CONTAINS_ALL: 19, NOT_CONTAINS_ALL: 20, CONTAINS_ANY: 21,
690
+ NOT_CONTAINS_ANY: 22, EQ_ANY: 23, NOT_EQ_ANY: 24,
691
+ });
692
+ function normalizeAggregationFormula(agg) {
693
+ const groups = agg.queryConditions;
694
+ if (groups === undefined)
695
+ return agg;
696
+ if (!Array.isArray(groups) || groups.some((group) => !Array.isArray(group)))
697
+ refuseFieldOptions("AGGREGATION aggregation.queryConditions is not an array of arrays", "rejected: aggregation.queryConditions 形状必须是 [[{propertyCode,propertyType(int),operatorType(int),propertyValue,valueType}],…](条件组)");
698
+ const normalized = groups.map((group) => group.map((leaf) => {
699
+ if (!leaf || typeof leaf !== "object" || Array.isArray(leaf))
700
+ refuseFieldOptions("AGGREGATION queryConditions leaf is not an object", "rejected: aggregation.queryConditions 每个条件必须是对​象");
701
+ const record = { ...leaf };
702
+ for (const key of ["propertyType", "currentPropertyType"]) {
703
+ const value = record[key];
704
+ if (value === undefined || value === null)
705
+ continue;
706
+ const code = typeof value === "number" ? value : FIELD_PROPERTY_TYPES[String(value).toUpperCase()];
707
+ if (typeof code !== "number" || !Number.isInteger(code))
708
+ refuseFieldOptions(`AGGREGATION queryConditions.${key} ${JSON.stringify(value)} is not a numeric BizPropertyType code`, `rejected: 聚合条件的 ${key} 必须是**整数**属性类型编码(Jackson 按 int 反序列化,传 "NUMERICAL" 会让 publish 50000);可用名字:${Object.keys(FIELD_PROPERTY_TYPES).join("、")}`);
709
+ record[key] = code;
710
+ }
711
+ const operator = record.operatorType;
712
+ if (operator !== undefined && operator !== null) {
713
+ const code = typeof operator === "number" ? operator : AGGREGATION_OPERATOR_CODES[String(operator).toUpperCase()];
714
+ if (typeof code !== "number" || !Number.isInteger(code) || code < 1 || code > 24)
715
+ refuseFieldOptions(`AGGREGATION queryConditions.operatorType ${JSON.stringify(operator)} is not an OperatorType code`, `rejected: 聚合条件的 operatorType 必须是整数 1–24(或名字:${Object.keys(AGGREGATION_OPERATOR_CODES).join("、")});传 "GTEQ" 会让 publish 50000`);
716
+ record.operatorType = code;
717
+ }
718
+ if (record.valueType === undefined)
719
+ record.valueType = "FIXED";
720
+ if (!["FIXED", "DYNAMIC"].includes(String(record.valueType)))
721
+ refuseFieldOptions(`AGGREGATION queryConditions.valueType ${JSON.stringify(record.valueType)} is not FIXED/DYNAMIC`, 'rejected: 聚合条件的 valueType 只能是 "FIXED" 或 "DYNAMIC"');
722
+ if (record.propertyValue !== undefined && typeof record.propertyValue !== "string")
723
+ record.propertyValue = String(record.propertyValue);
724
+ return record;
725
+ }));
726
+ return { ...agg, queryConditions: normalized };
727
+ }
675
728
  /** FORMULA formulaConfig decode; policy: string-only face, must parse to a non-array object. */
676
729
  function parseOptionsObject(value) {
677
730
  return decodeJsonObjectColumn(value, { invalid: "undefined", array: "reject", object: "reject" });
@@ -843,7 +896,12 @@ export function assertFieldOptionsAllowed(type, options, schemaCode) {
843
896
  else {
844
897
  const agg = parseOptionsObject(config.aggregation);
845
898
  if (!agg || !isNonEmptyString(agg.schemaCode) || !isNonEmptyString(agg.dataItemCode) || !isNonEmptyString(agg.formula))
846
- refuseFieldOptions("AGGREGATION aggregation is missing schemaCode/dataItemCode/formula", 'rejected: aggregation 形状 {schemaCode:"<子表schemaCode>", dataItemCode:"<子表字段code>", formula:"SUM({<子表schemaCode>.<子表字段code>})", calculateType?:.., queryConditions?:[]}(FormulaProcessor.executeMainAggregation + AggregationFormula)');
899
+ refuseFieldOptions("AGGREGATION aggregation is missing schemaCode/dataItemCode/formula", 'rejected: aggregation 形状 {schemaCode:"<目标模型schemaCode>", dataItemCode:"<目标字段code>", formula:"SUM({<目标模型>.<字段>})", calculateType:"SUM", queryConditions:[[…]]}(FormulaProcessor.executeMainAggregation + AggregationFormula)');
900
+ const normalizedAgg = normalizeAggregationFormula(agg);
901
+ if (normalizedAgg !== agg) {
902
+ config.aggregation = JSON.stringify(normalizedAgg);
903
+ options.formulaConfig = JSON.stringify(config);
904
+ }
847
905
  }
848
906
  }
849
907
  else if (!isNonEmptyString(config.expression)) {
@@ -615,6 +615,55 @@ export function graphGeometryNeedsLayout(nodesInput, routesInput) {
615
615
  const boxes = nodes.map(boxOf);
616
616
  return boxes.some((a, ai) => boxes.some((b, bi) => bi > ai && a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height));
617
617
  }
618
+ /**
619
+ * System persistence nodes belong to the TOOL, never to the caller.
620
+ *
621
+ * `SYSTEM_CREATE/SYSTEM_UPDATE/SYSTEM_DELETE` are the platform's trigger-row
622
+ * persistence nodes: the correct one is a function of the rule code
623
+ * (Create→SYSTEM_CREATE, …) and its position is a function of the action nodes.
624
+ * A caller who hand-writes one can only get it wrong:
625
+ *
626
+ * - wrong type inside a preset rule → data.submit 50000 (live 2026-09-25);
627
+ * - any type inside a CUSTOM rule bound to a workflow event → the end/cancel
628
+ * agenda has no serializable current object and every run throws
629
+ * `Object <id> is not serialized` (live 2026-10-22, r8: 33 occurrences,
630
+ * nothing written, every step still reported DELIVERED);
631
+ * - right type but wrong seat → the merged result is persisted before the
632
+ * action writes it (ASSIGN/BIZ_ACTION must precede the system node).
633
+ *
634
+ * So strip whatever the caller supplied and let `injectDefaultSystemNode` place
635
+ * the one platform default (preset rules only). Routes through the stripped
636
+ * node are bridged pre→post so the chain stays connected.
637
+ */
638
+ export function stripCallerSystemNodes(nodesInput, routesInput) {
639
+ const nodes = asRecordArray(parseMaybeJson(nodesInput));
640
+ const routes = asRecordArray(parseMaybeJson(routesInput));
641
+ const systemTypes = new Set(["SYSTEM_CREATE", "SYSTEM_UPDATE", "SYSTEM_DELETE"]);
642
+ const stripped = nodes
643
+ .filter((node) => systemTypes.has(String(node.nodeType ?? "")))
644
+ .map((node) => String(node.nodeCode ?? ""));
645
+ if (stripped.length === 0)
646
+ return { nodes, routes, stripped };
647
+ let nextRoutes = routes;
648
+ for (const code of stripped) {
649
+ const incoming = nextRoutes.filter((route) => String(route.postNode ?? "") === code).map((route) => String(route.preNode ?? ""));
650
+ const outgoing = nextRoutes.filter((route) => String(route.preNode ?? "") === code).map((route) => String(route.postNode ?? ""));
651
+ nextRoutes = nextRoutes.filter((route) => String(route.preNode ?? "") !== code && String(route.postNode ?? "") !== code);
652
+ for (const pre of incoming)
653
+ for (const post of outgoing)
654
+ nextRoutes.push({ preNode: pre, postNode: post });
655
+ }
656
+ // Collapse duplicate edges produced by bridging (same endpoints + condition).
657
+ const seen = new Set();
658
+ nextRoutes = nextRoutes.filter((route) => {
659
+ const key = `${String(route.preNode ?? "")}->${String(route.postNode ?? "")}#${JSON.stringify(route.routeCondition ?? null)}`;
660
+ if (seen.has(key))
661
+ return false;
662
+ seen.add(key);
663
+ return true;
664
+ });
665
+ return { nodes: nodes.filter((node) => !systemTypes.has(String(node.nodeType ?? ""))), routes: nextRoutes, stripped };
666
+ }
618
667
  /**
619
668
  * The single rule-graph normalization used by BOTH the write preflight and the
620
669
  * executor, so preview and execute validate the SAME prepared graph (CFG-02
@@ -632,10 +681,20 @@ export function graphGeometryNeedsLayout(nodesInput, routesInput) {
632
681
  * in `warnings` (never gating) so the caller can replace it with a domain name.
633
682
  */
634
683
  export function prepareRuleGraph(ruleCode, graph, customTrigger, schemaCode) {
635
- const injected = injectDefaultSystemNode(ruleCode, graph.nodes ?? graph.node, graph.routes ?? graph.route);
684
+ const warnings = [];
685
+ // The caller never owns a persistence node (see stripCallerSystemNodes).
686
+ const stripped = stripCallerSystemNodes(graph.nodes ?? graph.node, graph.routes ?? graph.route);
687
+ for (const nodeCode of stripped.stripped)
688
+ warnings.push({
689
+ code: "rule-graph-system-node-tool-owned",
690
+ nodeCode,
691
+ key: "nodes",
692
+ severity: "warning",
693
+ message: `已忽略调用方传入的系统持久化节点 ${nodeCode}:SYSTEM_CREATE/SYSTEM_UPDATE/SYSTEM_DELETE 由工具按规则类型注入,手写只会错配类型或位置(预置规则错配 → submit 50000;自定义规则/流程事件 → Object is not serialized 且静默不生效)`,
694
+ });
695
+ const injected = injectDefaultSystemNode(ruleCode, stripped.nodes, stripped.routes);
636
696
  let finalNodes = injected.nodes;
637
697
  let finalRoutes = injected.routes;
638
- const warnings = [];
639
698
  // SCHEDULER ⇒ TIMING_TASK: the platform dispatches a schedule ONLY when the
640
699
  // RULE ROW's bizRuleType = 2 (BizRuleScheduling.java:202-205; see
641
700
  // lanes/businessrule-contract.ts:42). A caller who authors a SCHEDULER node but
@@ -733,6 +792,37 @@ export function prepareRuleGraph(ruleCode, graph, customTrigger, schemaCode) {
733
792
  warnings.push({ code: "rule-graph-input-param-defaulted", nodeCode: String(node.nodeCode ?? ""), key: "inputParam", severity: "warning", message: `${nodeType} "${String(node.nodeCode ?? "")}" 缺 inputParam,已默认补 "_input"(平台按 inputParam 解析节点输入 schema,缺则 publish 300010「数据模型编码不能为空」)` });
734
793
  }
735
794
  }
795
+ // ASSIGN's quote stage dereferences three keys without a null check:
796
+ // `RuleAssignmentQuoteImpl.quote:61` calls `getNonMatchingAdded().booleanValue()`
797
+ // and `:77` does the same per child mapping. A graph that omits it therefore
798
+ // publishes as 50000 "服务异常" with an NPE in the launcher log — live
799
+ // 2026-10-22 (r8 seal sandbox), reproduced on a scratch model with an
800
+ // otherwise valid graph. The designer always writes them, so default the
801
+ // whole designer set instead of leaving a landmine.
802
+ if (nodeType === "ASSIGN") {
803
+ const defaults = [
804
+ ["triggerType", "CURRENT"],
805
+ ["dataCondition", { conditionGroup: [] }],
806
+ ["ruleVariableDataActions", []],
807
+ ["orderBys", []],
808
+ ["filterCondition", { mainConditions: [], sheetConditions: [] }],
809
+ ["dataActions", []],
810
+ ["children", []],
811
+ ["nonMatchingAdded", false],
812
+ ["addChildDataActions", []],
813
+ ];
814
+ const missing = defaults.filter(([key]) => current[key] === undefined || current[key] === null);
815
+ if (missing.length > 0) {
816
+ current = { ...current, ...Object.fromEntries(missing) };
817
+ warnings.push({
818
+ code: "rule-graph-assign-keys-defaulted",
819
+ nodeCode: String(node.nodeCode ?? ""),
820
+ key: "nonMatchingAdded",
821
+ severity: "warning",
822
+ message: `ASSIGN "${String(node.nodeCode ?? "")}" 缺设计器固定键(${missing.map(([key]) => key).join("、")}),已按设计器默认补齐——尤其 nonMatchingAdded:平台 quote 阶段直接 .booleanValue(),缺它会 publish 50000(NPE RuleAssignmentQuoteImpl.quote:61)`,
823
+ });
824
+ }
825
+ }
736
826
  if (String(current.nodeName ?? "").trim())
737
827
  return current;
738
828
  const nodeCode = String(current.nodeCode ?? "");
@@ -864,7 +954,7 @@ export const RULE_NODE_TEMPLATES = Object.freeze({
864
954
  },
865
955
  },
866
956
  ASSIGN: {
867
- description: "数据赋值(真机 2026-09-28 复核:可发布、可运行)。**`dataCondition` = 执行门**(输入数据满足条件才执行该节点,\"输入数据满足条件时触发执行动作\");**`filterCondition` = 按条件在输入数据源中匹配要取值的行**(\"根据此条件在输入数据源中进行匹配。匹配到多条数据时,会按第一条数据执行赋值\")——**两者是两码事,别混**:要\"满足条件才赋值\"用 `dataCondition`,不是 `filterCondition`(后者不拦执行)。`triggerType` ∈ CURRENT(当前数据)/RULE_VARIABLE(规则变量);CURRENT 时 `targetObjectCode` 默认 `\"_input\"`(=当前/输入对象),dataActions 写目标字段;RULE_VARIABLE 时 targetObjectCode 隐藏、dataActions 写 `_var_*`、`orderBys` 有默认 `[{code:\"createdTime\",direction:\"ASC\"}]`。**CURRENT+dataActions 必须排在系统持久化节点(SYSTEM_CREATE/SYSTEM_UPDATE)之前**(Start→ASSIGN→SYSTEM_CREATE→End),否则赋值合并进 _input 晚于持久化、读回无变化(工具 fail closed `rule-graph-assign-persistence-order`;工具注入系统节点时也会自动排到 ASSIGN/BIZ_ACTION 之后)。平台设计器键(SHEET_ASSIGN)required:nodeCode/nodeName/targetObjectCode/ruleVariableDataActions/dataActions/orderBys/addChildDataActions;dataCondition 默认 `{conditionGroup:[]}`、filterCondition 默认 `{mainConditions:[],sheetConditions:[]}`、nonMatchingAdded 默认 false、children/addChildDataActions 默认 []。",
957
+ description: "数据赋值:把输入里的值写进 `targetObjectCode` 指向的**目标行**(`targetObjectCode` 是**写入目标**,不是取数来源;缺省 `_input` = 当前/输入对象)。实测 2026-10-22(1.1.25):`targetObjectCode:\"_input\"` + `dataActions[{targetDataItem:\"<本单字段>\", ruleValueType:\"DYNAMIC\", currentDataItemValue:\"<本单来源字段>\"}]` 可发布可启用,目标字段是关联单选的也能写。**写成 `targetObjectCode:<别的模型>` + 目标是本单字段 → 平台自检 550018**(它按 targetObjectCode 校验 targetDataItem);**漏 `nonMatchingAdded` → quote 阶段 NPE,publish 50000**(`RuleAssignmentQuoteImpl.quote:61`;工具已按设计器键补齐并给 warning `rule-graph-assign-keys-defaulted`)。**`dataCondition` = 执行门**(输入数据满足条件才执行该节点,\"输入数据满足条件时触发执行动作\");**`filterCondition` = 按条件在输入数据源中匹配要取值的行**(\"根据此条件在输入数据源中进行匹配。匹配到多条数据时,会按第一条数据执行赋值\")——**两者是两码事,别混**:要\"满足条件才赋值\"用 `dataCondition`,不是 `filterCondition`(后者不拦执行)。`triggerType` ∈ CURRENT(当前数据)/RULE_VARIABLE(规则变量);CURRENT 时 `targetObjectCode` 默认 `\"_input\"`(=当前/输入对象),dataActions 写目标字段;RULE_VARIABLE 时 targetObjectCode 隐藏、dataActions 写 `_var_*`、`orderBys` 有默认 `[{code:\"createdTime\",direction:\"ASC\"}]`。**CURRENT+dataActions 必须排在系统持久化节点(SYSTEM_CREATE/SYSTEM_UPDATE)之前**(Start→ASSIGN→SYSTEM_CREATE→End),否则赋值合并进 _input 晚于持久化、读回无变化(工具 fail closed `rule-graph-assign-persistence-order`;工具注入系统节点时也会自动排到 ASSIGN/BIZ_ACTION 之后)。平台设计器键(SHEET_ASSIGN)required:nodeCode/nodeName/targetObjectCode/ruleVariableDataActions/dataActions/orderBys/addChildDataActions;dataCondition 默认 `{conditionGroup:[]}`、filterCondition 默认 `{mainConditions:[],sheetConditions:[]}`、nonMatchingAdded 默认 false、children/addChildDataActions 默认 []。",
868
958
  template: {
869
959
  nodeCode: "Assign_1", nodeName: "数据赋值", nodeType: "ASSIGN", nodeCategory: "actionNode", triggerType: "CURRENT", inputParam: "_input",
870
960
  dataCondition: { conditionGroup: [] }, dataConditionVisible: false,
@@ -1115,6 +1205,23 @@ export function formulaWarnings(action, nodeCode, index) {
1115
1205
  }
1116
1206
  /** Validate one dataAction; returns blockers (empty = clean). */
1117
1207
  export function validateDataAction(action, nodeCode, index) {
1208
+ // 1.1 (r8 2026-10-22): a DYNAMIC source written as a dotted path is parsed by
1209
+ // DatasetImpl as a CHILD-TABLE column — it collects that column across all
1210
+ // child rows, so a scalar target receives a COLLECTION. The graph saves and
1211
+ // publishes, then the runtime write answers 50000
1212
+ // (WorkSheetBizField.fillClientContent: "Incorrect value type, should be
1213
+ // string or map"). The engine reads a single-hop relation by its bare field
1214
+ // code (takes .id); there is no supported dotted scalar shape.
1215
+ {
1216
+ const source = String(action.currentDataItemValue ?? action.currentSchemaDataItem ?? "").trim();
1217
+ if (String(action.ruleValueType ?? "").toUpperCase() === "DYNAMIC" && source.includes("."))
1218
+ return [{
1219
+ code: "rule-graph-create-dynamic-source-is-child-collection",
1220
+ nodeCode,
1221
+ key: `dataActions[${index}].currentDataItemValue`,
1222
+ message: `${nodeCode}: DYNAMIC 来源 "${source}" 用了点号——引擎把 "x.y" 当**子表列集合**解析(DatasetImpl),不是"关联字段.id",标量目标会收到集合,运行期 submit/update 报 50000(WorkSheetBizField.fillClientContent: Incorrect value type, should be string or map)。单跳关联字段直接用字段 code(引擎取 .id);子表列只用于公式/聚合(FORMULA AGGREGATION),不能作为规则赋值来源。来源:r8 印章沙盒 2026-10-22。`,
1223
+ }];
1224
+ }
1118
1225
  const blockers = [];
1119
1226
  const where = `dataActions[${index}]`;
1120
1227
  const valueType = String(action.ruleValueType ?? "");
@@ -1451,6 +1558,7 @@ export function validateRuleNodeGraph(graph, ruleCode, schemaCode, options = {})
1451
1558
  nodeCode: String(crossModel[0].nodeCode ?? ""),
1452
1559
  key: "targetObjectCode",
1453
1560
  severity: "warning",
1561
+ ...(updateOnlyTrigger ? { hazard: true } : {}),
1454
1562
  message: updateOnlyTrigger
1455
1563
  ? `跨模型写回节点位于预置 Update 规则中:Update 规则只在 data.update 时触发,create/submit 不会执行它(真机 2026-09-28 复核)。要让回写在订单提交时生效,请把该 UPDATE 节点放进本模型的 Create 规则,或放进绑定到流程事件(如流程 end)的规则。`
1456
1564
  : `跨模型写回(目标 "${String(crossModel[0].targetObjectCode)}"):平台支持,运行时生效的两个前提——① 承载它的规则必须真的触发(Create 在提交时触发,Update 只在 data.update 触发;也可绑定流程事件);② 目标行必须已提交(COMMITTED),目标查询不匹配 DRAFT 行,data.save 未提交的行不会被回写。`,
@@ -913,7 +913,15 @@ function activityCodeOf(record) {
913
913
  * change still mints a collision-free fresh code. Returns the rewritten
914
914
  * activities plus the original→final code map for route remapping.
915
915
  */
916
- function reconcileCallerActivityCodes(callerActivities, draftActivities) {
916
+ function reconcileCallerActivityCodes(callerActivities, draftActivities, options = {}) {
917
+ // 8.6 pins activityType on an existing code (live 2026-09-23), so a retype
918
+ // there needs a fresh code. AI-Platform 1.1.x does NOT pin it: a raw
919
+ // workflow/update that keeps the code and changes activityType returns
920
+ // errcode 0 and the draft reads back with the new type (live 2026-10-21,
921
+ // 1.1.25, END→PARTICIPANT in place). Minting `_2` there is OMC's own
922
+ // invention and churns every downstream reference (routes, buttons, logs),
923
+ // so only the pinned family mints.
924
+ const mintOnTypeChange = options.mintOnTypeChange !== false;
917
925
  const draftByCode = new Map(draftActivities.map((activity) => [String(activity.activityCode ?? ""), activity]));
918
926
  const used = new Set(draftActivities.map((activity) => String(activity.activityCode ?? "")));
919
927
  const codeMap = new Map();
@@ -941,7 +949,7 @@ function reconcileCallerActivityCodes(callerActivities, draftActivities) {
941
949
  // the designer's own rename posts exactly that and activityName stays
942
950
  // the first-created value). Only a TYPE change still needs a fresh code
943
951
  // (activityType is pinned for existing codes).
944
- if (typeChanged) {
952
+ if (typeChanged && mintOnTypeChange) {
945
953
  let suffix = 2;
946
954
  while (used.has(`${original}_${suffix}`))
947
955
  suffix += 1;
@@ -1229,7 +1237,7 @@ async function refuseRequiredOnChildTableFields(session, schemaCode, template, t
1229
1237
  * same edge). This is the "read back, then send the delta" path — without it a
1230
1238
  * subset silently REPLACED the whole activity list and dropped nodes.
1231
1239
  */
1232
- function mergeCallerDelta(template, draft) {
1240
+ function mergeCallerDelta(template, draft, options = {}) {
1233
1241
  const { routes: routesRaw, activities: _raw, edges: edgesRaw, ...rest } = template;
1234
1242
  const routes = routesRaw ?? edgesRaw;
1235
1243
  const draftActivities = Array.isArray(draft.activities) ? draft.activities : [];
@@ -1258,7 +1266,7 @@ function mergeCallerDelta(template, draft) {
1258
1266
  // that under the same code). reconcileCallerActivityCodes keeps the code for
1259
1267
  // a name change and stamps name_i18n; only a type change still mints a fresh
1260
1268
  // code and DROPS the old node (a retype, not a rename).
1261
- const { activities: reconciledCaller, codeMap } = reconcileCallerActivityCodes(caller, draftActivities);
1269
+ const { activities: reconciledCaller, codeMap } = reconcileCallerActivityCodes(caller, draftActivities, options);
1262
1270
  const draftByCode = new Map(draftActivities.map((activity) => [String(activity.activityCode ?? ""), activity]));
1263
1271
  const originalOf = new Map();
1264
1272
  for (const [original, final] of codeMap)
@@ -1342,7 +1350,7 @@ function mergeCallerDelta(template, draft) {
1342
1350
  });
1343
1351
  return { ...rest, activities: laidOut.activities, rules };
1344
1352
  }
1345
- function normalizeWorkflowTemplate(template, draft) {
1353
+ function normalizeWorkflowTemplate(template, draft, options = {}) {
1346
1354
  if (template === undefined || template === null)
1347
1355
  return {};
1348
1356
  const { routes: routesRaw, activities: rawActivities, edges: edgesRaw, ...rest } = template;
@@ -1356,7 +1364,7 @@ function normalizeWorkflowTemplate(template, draft) {
1356
1364
  // The platform cannot rename an existing activityCode (see the reconcile
1357
1365
  // comment); give every changed/new node a fresh code, and remap caller routes
1358
1366
  // so they follow.
1359
- const reconciled = rawCaller ? reconcileCallerActivityCodes(rawCaller, draftActivities) : undefined;
1367
+ const reconciled = rawCaller ? reconcileCallerActivityCodes(rawCaller, draftActivities, options) : undefined;
1360
1368
  const activities = reconciled?.activities;
1361
1369
  const codeMap = reconciled?.codeMap ?? new Map();
1362
1370
  const remapCode = (value) => codeMap.get(String(value ?? "")) ?? value;
@@ -1477,7 +1485,13 @@ async function mergedTemplateBody(session, schemaCode, code, template, merge = f
1477
1485
  workflowCode: code,
1478
1486
  message: `流程 ${code} 不存在(get_draft 401001 缺席)——先 workflow.create 再 update/publish`,
1479
1487
  });
1480
- const normalized = merge ? mergeCallerDelta(template, draft) : normalizeWorkflowTemplate(template, draft);
1488
+ // Type changes are only pinned on the 8.x family (see reconcileCallerActivityCodes).
1489
+ // Unknown versions keep the conservative 8.x behaviour (mint) — writes fail
1490
+ // closed elsewhere, and a spurious `_2` is recoverable while a silent
1491
+ // in-place retype on a pinned platform is not.
1492
+ const version = session.detectedVersion();
1493
+ const mintOnTypeChange = !(version !== undefined && version.startsWith("1."));
1494
+ const normalized = merge ? mergeCallerDelta(template, draft, { mintOnTypeChange }) : normalizeWorkflowTemplate(template, draft, { mintOnTypeChange });
1481
1495
  // S29 contract: a PARTICIPANT activity requires sheetCode = the schema's
1482
1496
  // sheet, else validate/publish answers 401026 WORKFLOW_ACTIVITY_NO_SHEET. The
1483
1497
  // platform's own draft sets each participant's sheetCode to the model code;
@@ -4,9 +4,10 @@ import { ENDPOINTS } from "./endpoints.js";
4
4
  import { ToolRefusalError } from "./tool-types.js";
5
5
  import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "./json-column.js";
6
6
  import { RULE_BIZ_RULE_TYPE_DESCRIPTION, RULE_CREATE_BODY, RULE_ROUTES_DESCRIPTION, ruleNodeGraphDescription, schedulerSettingDescription, } from "./lanes/businessrule-contract.js";
7
- import { validateRuleNodeGraph, prepareRuleGraph, PLATFORM_TRIGGER_RULE_CODES } from "./lanes/rule-graph.js";
7
+ import { validateRuleNodeGraph, prepareRuleGraph, PLATFORM_TRIGGER_RULE_CODES, DEFAULT_SYSTEM_NODE_BY_RULE_CODE } from "./lanes/rule-graph.js";
8
8
  import { compileMethodConfigPlain } from "./bizservice-config.js";
9
9
  import { RESERVED_CODES } from "./code-rules.js";
10
+ import { FIELD_PROPERTY_TYPES } from "./field-families.js";
10
11
  /** Business-rule node graph. Platform trigger rules are addressed by ruleCode: Create / Update / Delete / Available / Cancel / GetList / Load. */
11
12
  export const ruleNodeGraphArg = z
12
13
  .object({
@@ -399,6 +400,108 @@ async function assertBizActionMappingFieldsExist(session, schemaCode, nodes) {
399
400
  * the schema against the authoritative model list BEFORE the first rule wire
400
401
  * call and refuses on miss — zero pollution.
401
402
  */
403
+ /**
404
+ * ASSIGN(数据赋值)is an OLD node — it is not new in 8.7/1.x. What changed is
405
+ * its SHAPE.
406
+ *
407
+ * Evidence (8.6.26 metadata):
408
+ * - `BusinessRuleUpgradeServiceImpl` batch-upgrades stored rules through
409
+ * `UpgradeRuleHandler`;
410
+ * - `UpgradeRuleHandler.upgradeAssignNode()` reads the legacy
411
+ * `triggerObjectCode`, writes `inputParam`+`targetObjectCode`, rewrites
412
+ * `currentDataItemValue` for child paths, and deletes
413
+ * `currentDataModel`/`targetSchemaModel`/`tableConditionGroups`/
414
+ * `tableConditionGroupsVisible`.
415
+ *
416
+ * An upgrade path only exists because older installations already stored ASSIGN
417
+ * in that legacy shape. So: ASSIGN exists before 8.6, the MODERN shape starts at
418
+ * 8.6, and AI-Platform 1.x uses the same modern shape (DataAssignNode in
419
+ * 1.1.25). OMC only writes the modern shape, so on a pre-8.6 environment it must
420
+ * refuse rather than send keys the target will silently misread.
421
+ */
422
+ export function assignNodes(nodes) {
423
+ return nodes
424
+ .filter((node) => String(node.nodeType ?? "") === "ASSIGN")
425
+ .map((node) => String(node.nodeCode ?? "ASSIGN"));
426
+ }
427
+ export function isModernAssignVersion(version) {
428
+ if (version === undefined || version.trim() === "")
429
+ return true;
430
+ const match = /^(\d+)\.(\d+)/u.exec(version.trim());
431
+ if (!match)
432
+ return true;
433
+ const major = Number(match[1]);
434
+ const minor = Number(match[2]);
435
+ if (major >= 1 && major < 8)
436
+ return true;
437
+ return major > 8 || (major === 8 && minor >= 6);
438
+ }
439
+ /**
440
+ * 1.2 (r8 2026-10-22, 1.1.25): a `dataCondition` that compares a text-CHOICE field
441
+ * (RADIO/CHECKBOX/DROPDOWN) with a FIXED value never evaluates — the launcher
442
+ * logs 「解析选框类型数据项的比较值失败」 and the node runs UNCONDITIONALLY (the
443
+ * customer's "异常归还" was written back as if it were "已归还"). This is a
444
+ * platform-boundary behaviour we cannot fix, but shipping a silent always-true
445
+ * gate is worse than warning: emit a non-gating warning naming the field and the
446
+ * supported alternatives (BRANCH, or two rules).
447
+ */
448
+ const CHOICE_CONDITION_TYPES = new Set([12, 13, 14, 15]);
449
+ async function radioConditionWarnings(session, schemaCode, nodes) {
450
+ const sources = new Set();
451
+ const collect = (condition) => {
452
+ if (!condition || typeof condition !== "object")
453
+ return;
454
+ const record = condition;
455
+ const groups = record.dataConditionGroups ?? record.conditionGroup ?? record.execConditionGroups;
456
+ if (!Array.isArray(groups))
457
+ return;
458
+ for (const group of groups) {
459
+ if (!Array.isArray(group))
460
+ continue;
461
+ for (const leaf of group) {
462
+ if (!leaf || typeof leaf !== "object")
463
+ continue;
464
+ const item = leaf;
465
+ if (String(item.ruleValueType ?? "").toUpperCase() !== "FIXED")
466
+ continue;
467
+ const source = String(item.currentSchemaDataItem ?? item.currentDataItemValue ?? "").trim();
468
+ if (source !== "" && !source.includes("."))
469
+ sources.add(source);
470
+ }
471
+ }
472
+ };
473
+ for (const node of nodes)
474
+ collect(node.dataCondition);
475
+ if (sources.size === 0)
476
+ return [];
477
+ let rows;
478
+ try {
479
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
480
+ }
481
+ catch {
482
+ return [];
483
+ }
484
+ const choice = rows.filter((row) => {
485
+ const code = String(row.code ?? row.propertyCode ?? "");
486
+ // The real wire carries `propertyType`; the fake/older projections carry
487
+ // `type`. Accept both (numbers, numeric strings, or family names).
488
+ const rawType = row.propertyType ?? row.type ?? row.typeName;
489
+ const type = typeof rawType === "number" ? rawType : FIELD_PROPERTY_TYPES[String(rawType ?? "").toUpperCase()] ?? Number(rawType);
490
+ return sources.has(code) && CHOICE_CONDITION_TYPES.has(type);
491
+ });
492
+ if (choice.length === 0)
493
+ return [];
494
+ return [
495
+ {
496
+ code: "rule-graph-radio-condition-unparseable",
497
+ nodeCode: "",
498
+ key: "dataCondition",
499
+ severity: "warning",
500
+ hazard: true,
501
+ message: `dataCondition 用 FIXED 值比较文本选择类字段(${choice.map((row) => String(row.name ?? row.code ?? "")).join("、")}):1.1.25 运行时日志「解析选框类型数据项的比较值失败」,条件**不生效**,节点会无条件执行(r8 印章沙盒 2026-10-22:异常归还也被当成已归还回写)。改用 BRANCH 判断该字段,或拆成两条规则;不要把「规则已 ENABLED」当作条件已生效。`,
502
+ },
503
+ ];
504
+ }
402
505
  export async function assertRuleSchemaExists(session, schemaCode) {
403
506
  // Authoritative per-schema probe: GET /bizmodels/get_all (BizSchemaController
404
507
  // .java:615-633) resolves getBizSchemaBySchemaCode and answers
@@ -554,9 +657,16 @@ export async function executeRuleSaveWrite(session, args) {
554
657
  // legacy warning 原样回传);新增/改动节点维持严格。存量永远可编辑。
555
658
  const carriedNodeCodes = await computeCarriedNodeCodes(session, args.schemaCode, args.ruleCode, preparedGraph);
556
659
  const blockers = [...prepared.warnings, ...validateRuleNodeGraph(preparedGraph, args.ruleCode, typeof args.schemaCode === "string" ? args.schemaCode : undefined, carriedNodeCodes ? { carriedNodeCodes } : {})];
660
+ // 1.2: needs the published field types (pure lint cannot see them).
661
+ blockers.push(...(await radioConditionWarnings(session, args.schemaCode, Array.isArray(preparedGraph.nodes) ? preparedGraph.nodes : [])));
557
662
  // Ticket 113: warning-severity findings (e.g. unknown mapping keys) do not
558
663
  // gate the write; they surface in the readback as lintWarnings.
559
- const lintWarnings = blockers.filter((blocker) => blocker.severity === "warning");
664
+ const warningFindings = blockers.filter((blocker) => blocker.severity === "warning");
665
+ // `hazard` warnings stay non-gating but are surfaced separately: the readback
666
+ // must not let an always-true condition or an inert trigger look like a
667
+ // plain advisory.
668
+ const lintWarnings = warningFindings.filter((blocker) => blocker.hazard !== true);
669
+ const lintHazards = warningFindings.filter((blocker) => blocker.hazard === true);
560
670
  if (blockers.some((blocker) => blocker.severity !== "warning")) {
561
671
  throw new ToolRefusalError({
562
672
  status: "refused",
@@ -583,6 +693,25 @@ export async function executeRuleSaveWrite(session, args) {
583
693
  // Ticket 96: phantom-schema gate — BEFORE the first businessrule wire call
584
694
  // (the rule list itself auto-creates preset rows for unknown schemas).
585
695
  await assertRuleSchemaExists(session, args.schemaCode);
696
+ // ASSIGN shape is version-dependent (see isModernAssignVersion).
697
+ const assignCodes = assignNodes(nodes);
698
+ if (assignCodes.length > 0) {
699
+ const version = session.detectedVersion();
700
+ if (!isModernAssignVersion(version)) {
701
+ throw new ToolRefusalError({
702
+ status: "refused",
703
+ blocker: "rule-graph-assign-version-unsupported",
704
+ schemaCode: args.schemaCode,
705
+ ruleCode: args.ruleCode,
706
+ version: version ?? "unknown",
707
+ nodes: assignCodes,
708
+ message: `检测到 CloudPivot ${version ?? "未知版本"}:「数据赋值」在 8.6 之前是旧形状(triggerObjectCode/currentDataModel/targetSchemaModel/tableConditionGroups),` +
709
+ `8.6.26 自带 BusinessRuleUpgradeServiceImpl + UpgradeRuleHandler.upgradeAssignNode 正是用来把旧图升级成 inputParam+targetObjectCode+nonMatchingAdded 的。` +
710
+ `也就是说 ASSIGN 本身不是新节点,**现代形状从 8.6 起**(AI-Platform 1.x 同形)。OMC 只写现代形状,在旧形状环境会静默误读。` +
711
+ `请改用 UPDATE/CREATE 表达同一逻辑,或在该版本的设计器里配置 ASSIGN。`,
712
+ });
713
+ }
714
+ }
586
715
  // Ticket 35: wire existence gate for BizMethod bindings — runs after the
587
716
  // local lint, before the create/update wire call (fail-closed; the platform
588
717
  // itself only gates the SERVICE, never the METHOD, at design time).
@@ -624,10 +753,36 @@ export async function executeRuleSaveWrite(session, args) {
624
753
  : typeof schedulerInput === "string"
625
754
  ? schedulerInput
626
755
  : JSON.stringify(schedulerInput);
627
- const nodeJson = typeof nodes === "string" ? nodes : JSON.stringify(nodes);
628
- const routeJson = typeof routes === "string" ? routes : JSON.stringify(routes);
629
756
  const list = await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode: args.schemaCode } });
630
757
  const existing = wireRows(list.data).find((row) => row.code === args.ruleCode);
758
+ // Persistence nodes belong to the PLATFORM, not to us and not to the caller.
759
+ // The preset row already carries its own (Start → Create(SYSTEM_CREATE) →
760
+ // End). Copy that node verbatim instead of fabricating our own: nodeCode,
761
+ // nodeName, isSystemNode and geometry then always match what the designer
762
+ // itself writes. Only its SEAT (relative to the caller's action nodes) is
763
+ // ours, and the fingerprint of a hand-made replica drifting from the
764
+ // platform's is exactly what breaks publish later.
765
+ const systemSpec = DEFAULT_SYSTEM_NODE_BY_RULE_CODE[args.ruleCode];
766
+ if (existing?.id && systemSpec) {
767
+ try {
768
+ const baselineRow = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id: String(existing.id) } })).data;
769
+ const baselineNodes = parseJsonColumn(baselineRow?.node);
770
+ const platformNode = Array.isArray(baselineNodes)
771
+ ? baselineNodes.find((node) => String(node.nodeType ?? "") === systemSpec.nodeType)
772
+ : undefined;
773
+ if (platformNode) {
774
+ const index = nodes.findIndex((node) => String(node.nodeType ?? "") === systemSpec.nodeType);
775
+ if (index >= 0)
776
+ nodes[index] = { ...platformNode, isSystemNode: true };
777
+ }
778
+ }
779
+ catch {
780
+ // Best-effort: the documented default keeps the chain valid if the
781
+ // baseline cannot be read (the save itself will report the real error).
782
+ }
783
+ }
784
+ const nodeJson = typeof nodes === "string" ? nodes : JSON.stringify(nodes);
785
+ const routeJson = typeof routes === "string" ? routes : JSON.stringify(routes);
631
786
  const schedulerField = schedulerSetting === undefined ? {} : { schedulerSetting };
632
787
  // Preset trigger rules (Create/Update/Delete/Load/GetList/Available/Cancel
633
788
  // and 流程 code) are platform-managed: their `name` is a platform default
@@ -694,6 +849,7 @@ export async function executeRuleSaveWrite(session, args) {
694
849
  enabled,
695
850
  status: effective?.status,
696
851
  ...(lintWarnings.length > 0 ? { lintWarnings } : {}),
852
+ ...(lintHazards.length > 0 ? { lintHazards } : {}),
697
853
  id: effective?.id ?? undefined,
698
854
  ruleVersion: effective?.ruleVersion,
699
855
  latestPublished: effective?.latestPublished,
@@ -710,6 +866,7 @@ export async function executeRuleSaveWrite(session, args) {
710
866
  published: false,
711
867
  enabled,
712
868
  ...(lintWarnings.length > 0 ? { lintWarnings } : {}),
869
+ ...(lintHazards.length > 0 ? { lintHazards } : {}),
713
870
  id: effective?.id ?? undefined,
714
871
  ruleVersion: effective?.ruleVersion,
715
872
  latestPublished: effective?.latestPublished,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.71",
3
+ "version": "0.4.73",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,9 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@stalfh233/omc-fake-platform": "0.4.71",
53
+ "@stalfh233/omc-fake-platform": "0.4.73",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.71",
55
+ "omc-core": "0.4.73",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -151,7 +151,15 @@ rules:
151
151
  - **两条路**:模型+表单+流程一揽子交付走**意图**(`intent` 的 `workflow` 能力,声明式一次 apply);只动流程或想逐步控制走**低层链** `workflow.create → workflow.update(权限/节点)→ workflow.publish`,标准链配方见 `omc describe workflow.update`(含多级审批/participant 三形态/自动回填说明)。流程权限细节见下节。
152
152
  - 一个模型只能有一个流程;已有流程的模型直接 `workflow.update`,不要另建。
153
153
  - **清理测试数据用批量删除**:`data.delete --args '{"schemaCode":"<code>","bizObjectIds":["id1","id2"]}'` 一次往返删多条(真机 r6:207 次逐条 `data.delete` 占 13.5% 调用)。
154
+ - **「聚合计算」是字段能力,不是规则节点**:FORMULA(计算公式)字段的 `options.formulaConfig.formulaType="AGGREGATION"` 就是 UI 里的「配置方式 → 聚合计算」,跨模型做 SUM/MAX/MIN/COUNT(1.1.25 实测可用,8.6.26 同形)。形状:
155
+ `options = {persistent:true, formulaConfig: JSON字符串({resultType:2, resultFormat:"", formulaType:"AGGREGATION", aggregation: JSON字符串({schemaCode:"<目标模型>", dataItemCode:"<目标字段>", calculateType:"SUM", formula:"SUM({<目标模型>.<字段>})", queryConditions:[[{propertyCode, propertyType, operatorType, propertyValue, valueType:"FIXED"}]]})})}`。
156
+ **`propertyType`/`operatorType` 必须写整数**(Jackson 按 int 反序列化枚举):propertyType 2=NUMERICAL、3=DATE…;operatorType 1=EQ / 2=NEQ / 3=GT / 4=IT / 5=GTEQ / 6=ITEQ / 9=IS_NULL / 10=IS_NOT_NULL …(工具接受名字并自动转成整数)。`queryConditions` 为**空数组时聚合结果是 0**(不是全表求和);目标模型的**草稿行不参与**(实测:DRAFT 行不入计数,提交后 10+32.5→42.5)。已发布的公式字段**不能改配置方式**(field.update 报 -1),要改就新建字段或趁草稿改。
157
+ - **DYNAMIC 来源不要写点号**:`dataActions` 的 `currentDataItemValue` 写 `items.sealNo` 会被引擎当成**子表列集合**(DatasetImpl),标量目标收到集合 → 运行期 `submit/update` 报 50000(`WorkSheetBizField.fillClientContent: Incorrect value type`)。单跳关联字段直接写字段 code(引擎取 `.id`);子表列只能用于公式/聚合。`rule.save` 已写前拒绝(`rule-graph-create-dynamic-source-is-child-collection`)。
158
+ - **单选字段的 `dataCondition` 在 1.1.25 不生效**:用 FIXED 值比较 RADIO/CHECKBOX/DROPDOWN 时,运行时日志「解析选框类型数据项的比较值失败」,条件被跳过、节点**无条件执行**(r8:异常归还被当成已归还回写)。`rule.save` 会把它放进 `readback.lintHazards`(`rule-graph-radio-condition-unparseable`);改用 BRANCH 判断该字段,或拆两条规则。
159
+ - **预置 Update 规则里的跨模型回写只在 `data.update` 触发**:`rule-graph-cross-model-update-trigger-inert` 现在是 hazard(在 `readback.lintHazards`,不混进 `lintWarnings`);create/submit 不执行它。要提交时生效,把节点放进 Create 规则或流程事件规则。
160
+ - **系统持久化节点是平台自带的,不要手写**:预置 `Create/Update/Delete` 行本来就带 `SYSTEM_CREATE/SYSTEM_UPDATE/SYSTEM_DELETE`(实测基线 `Start → Create(SYSTEM_CREATE) → End`)。`rule.save` 会丢弃调用方传入的 `SYSTEM_*` 并**从平台基线原样携带**那一个节点(code/name/几何都不由我们猜)。手写的后果:预置规则里类型错配 → `data.submit` 50000;自定义规则/流程事件里带系统节点 → 结束事件无当前对象,`Object <id> is not serialized`,每步仍回 DELIVERED 但什么都没写。只写平台允许配置的业务节点(UPDATE/CREATE/DELETE/BRANCH/ASSIGN/MESSAGE/BIZ_ACTION…)。
154
161
  - **取 artifact 先投影**:`artifact get <id> --summary` 看形状 → `--jq '<path>'` 取那一段;只有确实要整份时才裸取(r6:47 次整包拉取 650KB,0 次用投影)。
162
+ - **读回产物会自动清理,也可手动清**:每次大结果落一个 artifact(机器级目录,跨项目累积;实测一轮沙盒 504 个/3.4MB)。`createArtifact` 每 24h 顺手删 7 天未动的;要立刻回收用 `omc artifact prune --older-than 7d --max-bytes 268435456`(默认预算 256MB,超出按最旧优先删),`--json` 回 removed/freedBytes/kept。
155
163
  - **核验状态用投影,别重复整条读**:`data.load --args '{"schemaCode":..,"bizObjectId":..,"fields":["sequenceStatus","<业务状态字段>"]}'` 只回指定字段(缺失项在 `missingFields`)——r4 实测同一对象被整条读 74 次;大结果先 `artifact get <id> --summary` → `--keys/--jq`,别整包内联进推理。
156
164
  - **流程第一个节点必须是 `{Originator}` 填写节点**:平台 `WorkflowTemplateValidateUtility.validateStartActivity` 对「START 后直接接审批」报 401035「发起节点的参与者只能是{Originator}」,publish 回 errcode 0 但一个 ValidateMessage[] 就把模板挡在门外、不落库(真机 2026-10-21)。OMC 0.4.65 起在 `workflow.update`/`publish` 出门前拒绝(`workflow-first-activity-not-originator`)。
157
165
  - **跨模型回写的目标行必须已提交(COMPLETED)**:DRAFT 行不参与目标查询,运行 0 行命中且日志只有「查找范围不存在数据」。单跳关联字段可直接作 DYNAMIC 来源(引擎取关联对象 id);点号路径按子表解析,`关联字段.id` / 两跳路径都不支持。
@@ -209,7 +217,9 @@ rules:
209
217
  规则支持**触发条件**,不要用「两条无条件规则」表达互斥结果:
210
218
 
211
219
  - **节点级条件有两大类,别混**(各节点类型都可能有,完整字段/样例见 `describe rule.save --full` 的 RULE_NODE_TEMPLATES):
212
- - **定位要操作哪些数据**(`filterCondition`:叶 `targetSchemaDataItem`+`ruleConditionType`):UPDATE=改哪条(空=整表)、DELETE=删哪条、CREATE=存在符合则不新增+`findMainData`、QUERY=`targetConditionGroups`、**ASSIGN=在输入数据源匹配要取值的行(不是执行门!)**。⚠️ 非唯一字段(金额等)会命中**多条**;改「当前/关联那一条」用唯一键(`id` 或 `from`)。`actions.filter` 表达这类(同模型省略→默认 `id=id`;跨模型省略 fail-closed)。
220
+ - **`ASSIGN`(数据赋值)不是新节点,新的是它的形状**:8.6.26 自带 `BusinessRuleUpgradeServiceImpl` + `UpgradeRuleHandler.upgradeAssignNode`,职责就是把旧形状(`triggerObjectCode`/`currentDataModel`/`targetSchemaModel`/`tableConditionGroups`)升级为 `inputParam`+`targetObjectCode`+`nonMatchingAdded`——存在升级器就说明 8.6 之前已经有 ASSIGN。**现代形状从 8.6 起**,AI-Platform 1.x 同形;<8.6 存的是旧形状,写现代形状会被静默误读,OMC 直接拒绝(`rule-graph-assign-version-unsupported`)。
221
+ - **`ASSIGN` 的 `targetObjectCode` 是写入目标,不是取数来源**:默认 `_input` = 当前单;`dataActions.targetDataItem` 必须是**该目标模型已发布字段**(写成别的模型 + 本单字段 → 550018)。要"从关联单取值回填本单",就 `targetObjectCode:"_input"` + `currentDataItemValue:"<本单来源字段>"`(关联字段引擎取 .id),不要把它指到对面模型。漏 `nonMatchingAdded` 会让 quote 阶段 NPE(publish 50000),工具已按设计器键补齐(warning `rule-graph-assign-keys-defaulted`)。
222
+ - **定位要操作哪些数据**(`filterCondition`:叶 `targetSchemaDataItem`+`ruleConditionType`):UPDATE=改哪条(空=整表)、DELETE=删哪条、CREATE=存在符合则不新增+`findMainData`、QUERY=`targetConditionGroups`、**ASSIGN=在目标表里匹配要赋值的行(不是执行门!)**。⚠️ 非唯一字段(金额等)会命中**多条**;改「当前/关联那一条」用唯一键(`id` 或 `from`)。`actions.filter` 表达这类(同模型省略→默认 `id=id`;跨模型省略 fail-closed)。
213
223
  - **执行门 / 满足条件触发**(`dataCondition`,BIZ_ACTION 用 `execConditionGroups`):**与定位两码事**——决定「要不要执行」,不决定「改哪条」。compact `actions.when` 表达 UPDATE 执行门(叶 `currentSchemaDataItem`+`ruleDataConditionType`);其它节点用 `nodeGraph`。**条件改自身**:`when: [{field: amount, gt: 10000}]` + 省略 `filter`(默认 `id=id`)——别把条件写进 `filter`,那会改掉所有符合的行(bench66 多行互覆)。
214
224
  - 子表定位 `filterSubCondition`;子表触发范围 `childTriggerConditionType∈ADD/MODIFY/DELETE`。
215
225
  - **触发时机**:数据事件(`Create`/`Update`/`Delete`)**只分发到平台预置 code 行**;流程相关用预置 `Available`(流程生效)/`Cancel`(流程作废),或流程事件绑定 `workflow.bindRule` 的 `end`/`cancel`/`start`/`activate`。