@stalfh233/omc-cli 0.4.71 → 0.4.72

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] : [])]); });
@@ -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
@@ -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,7 +4,7 @@ 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
10
  /** Business-rule node graph. Platform trigger rules are addressed by ruleCode: Create / Update / Delete / Available / Cancel / GetList / Load. */
@@ -624,10 +624,36 @@ export async function executeRuleSaveWrite(session, args) {
624
624
  : typeof schedulerInput === "string"
625
625
  ? schedulerInput
626
626
  : JSON.stringify(schedulerInput);
627
- const nodeJson = typeof nodes === "string" ? nodes : JSON.stringify(nodes);
628
- const routeJson = typeof routes === "string" ? routes : JSON.stringify(routes);
629
627
  const list = await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode: args.schemaCode } });
630
628
  const existing = wireRows(list.data).find((row) => row.code === args.ruleCode);
629
+ // Persistence nodes belong to the PLATFORM, not to us and not to the caller.
630
+ // The preset row already carries its own (Start → Create(SYSTEM_CREATE) →
631
+ // End). Copy that node verbatim instead of fabricating our own: nodeCode,
632
+ // nodeName, isSystemNode and geometry then always match what the designer
633
+ // itself writes. Only its SEAT (relative to the caller's action nodes) is
634
+ // ours, and the fingerprint of a hand-made replica drifting from the
635
+ // platform's is exactly what breaks publish later.
636
+ const systemSpec = DEFAULT_SYSTEM_NODE_BY_RULE_CODE[args.ruleCode];
637
+ if (existing?.id && systemSpec) {
638
+ try {
639
+ const baselineRow = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id: String(existing.id) } })).data;
640
+ const baselineNodes = parseJsonColumn(baselineRow?.node);
641
+ const platformNode = Array.isArray(baselineNodes)
642
+ ? baselineNodes.find((node) => String(node.nodeType ?? "") === systemSpec.nodeType)
643
+ : undefined;
644
+ if (platformNode) {
645
+ const index = nodes.findIndex((node) => String(node.nodeType ?? "") === systemSpec.nodeType);
646
+ if (index >= 0)
647
+ nodes[index] = { ...platformNode, isSystemNode: true };
648
+ }
649
+ }
650
+ catch {
651
+ // Best-effort: the documented default keeps the chain valid if the
652
+ // baseline cannot be read (the save itself will report the real error).
653
+ }
654
+ }
655
+ const nodeJson = typeof nodes === "string" ? nodes : JSON.stringify(nodes);
656
+ const routeJson = typeof routes === "string" ? routes : JSON.stringify(routes);
631
657
  const schedulerField = schedulerSetting === undefined ? {} : { schedulerSetting };
632
658
  // Preset trigger rules (Create/Update/Delete/Load/GetList/Available/Cancel
633
659
  // and 流程 code) are platform-managed: their `name` is a platform default
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.72",
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.72",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.71",
55
+ "omc-core": "0.4.72",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -151,7 +151,9 @@ 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
+ - **系统持久化节点是平台自带的,不要手写**:预置 `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
155
  - **取 artifact 先投影**:`artifact get <id> --summary` 看形状 → `--jq '<path>'` 取那一段;只有确实要整份时才裸取(r6:47 次整包拉取 650KB,0 次用投影)。
156
+ - **读回产物会自动清理,也可手动清**:每次大结果落一个 artifact(机器级目录,跨项目累积;实测一轮沙盒 504 个/3.4MB)。`createArtifact` 每 24h 顺手删 7 天未动的;要立刻回收用 `omc artifact prune --older-than 7d --max-bytes 268435456`(默认预算 256MB,超出按最旧优先删),`--json` 回 removed/freedBytes/kept。
155
157
  - **核验状态用投影,别重复整条读**:`data.load --args '{"schemaCode":..,"bizObjectId":..,"fields":["sequenceStatus","<业务状态字段>"]}'` 只回指定字段(缺失项在 `missingFields`)——r4 实测同一对象被整条读 74 次;大结果先 `artifact get <id> --summary` → `--keys/--jq`,别整包内联进推理。
156
158
  - **流程第一个节点必须是 `{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
159
  - **跨模型回写的目标行必须已提交(COMPLETED)**:DRAFT 行不参与目标查询,运行 0 行命中且日志只有「查找范围不存在数据」。单跳关联字段可直接作 DYNAMIC 来源(引擎取关联对象 id);点号路径按子表解析,`关联字段.id` / 两跳路径都不支持。