@stalfh233/omc-cli 0.4.70 → 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);
@@ -968,12 +984,33 @@ async function main() {
968
984
  command.command("setup").option("--list").option("--edit <environment>").option("--remove <environment>").action(async (options) => {
969
985
  commandExitCode = await cmdSetup(new ConfigStore(defaultPaths()), [...(options.list ? ["--list"] : []), ...(options.edit ? ["--edit", options.edit] : []), ...(options.remove ? ["--remove", options.remove] : [])]);
970
986
  });
971
- command.command("env [action]").option("--json").action(async (action, options) => {
987
+ command.command("env [action] [name]").option("--json").action(async (action, name, options) => {
972
988
  // Read-only inventory of configured environment names. r18: the sandbox
973
989
  // guessed names instead of listing them; doctor's wording now points here.
974
990
  const store = new ConfigStore(defaultPaths());
991
+ if (action === "allow") {
992
+ // The only sanctioned way to widen a project's environment scope. The
993
+ // list stays project-scoped until the operator names the environment —
994
+ // an agent cannot discover another project's environments from here.
995
+ const target = name;
996
+ if (!target) {
997
+ console.error("用法:omc env allow <环境名>(把该项目需要的另一环境写入 .omc-envs;执行前须向用户报告并取得同意)");
998
+ commandExitCode = CLI_EXIT_CODES.invalidInput;
999
+ return;
1000
+ }
1001
+ try {
1002
+ store.allowProjectEnvironment(target);
1003
+ console.error(`已允许本项目使用环境 ${target}(写入 .omc-envs)`);
1004
+ commandExitCode = CLI_EXIT_CODES.ok;
1005
+ }
1006
+ catch (error) {
1007
+ console.error(error.message);
1008
+ commandExitCode = CLI_EXIT_CODES.invalidInput;
1009
+ }
1010
+ return;
1011
+ }
975
1012
  if (action !== undefined && action !== "list") {
976
- console.error("用法:omc env list [--json]");
1013
+ console.error("用法:omc env list [--json] | omc env allow <环境名>");
977
1014
  commandExitCode = CLI_EXIT_CODES.invalidInput;
978
1015
  return;
979
1016
  }
@@ -1050,7 +1087,34 @@ async function main() {
1050
1087
  rollbackCommand.command("apply <ref>").option("--env <environment>").option("--environment <environment>").option("--confirm-production").action(async (ref, options) => {
1051
1088
  commandExitCode = await withTransport(options, "rollback apply", (transport) => cmdRollbackApply(ref, { environment: options.env ?? options.environment, confirmProduction: options.confirmProduction === true }, transport));
1052
1089
  });
1053
- 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) => {
1054
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 } : {}) }));
1055
1119
  });
1056
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] : [])]); });
@@ -23,6 +23,7 @@ export function defaultPaths(projectDir = process.cwd()) {
23
23
  configPath: join(configRoot, "omc.config.json"),
24
24
  credentialsPath: join(configRoot, "credentials"),
25
25
  projectEnvPath: join(resolve(projectDir), ".omc-env"),
26
+ projectEnvsPath: join(resolve(projectDir), ".omc-envs"),
26
27
  };
27
28
  }
28
29
  export class ConfigStore {
@@ -124,8 +125,55 @@ export class ConfigStore {
124
125
  this.writeConfig({ environments });
125
126
  return config;
126
127
  }
127
- listEnvironments() {
128
- return Object.entries(this.readConfig().environments).map(([name, profile]) => ({ name, profile }));
128
+ /**
129
+ * Environments visible to THIS project. Project isolation is the default:
130
+ * the caller only ever sees the project's own environments, never the rest of
131
+ * the machine's config (a co-located project's baseUrl/credentials are its
132
+ * own business). `all:true` is for the operator-facing setup command, which
133
+ * must explicitly ask before revealing another project's environment.
134
+ */
135
+ listEnvironments(options = {}) {
136
+ const all = Object.entries(this.readConfig().environments).map(([name, profile]) => ({ name, profile }));
137
+ if (options.all === true)
138
+ return all;
139
+ const own = new Set(this.ownEnvironmentNames());
140
+ return all.filter((entry) => own.has(entry.name));
141
+ }
142
+ /** Names declared by this project: `.omc-envs` lines + the `.omc-env` binding. */
143
+ ownEnvironmentNames() {
144
+ const names = new Set(this.readProjectEnvsFile());
145
+ const bound = this.readProjectEnvBinding();
146
+ if (bound)
147
+ names.add(bound);
148
+ if (names.size === 0) {
149
+ const configured = this.listEnvironments({ all: true });
150
+ const fallback = configured.find((entry) => entry.profile.isDefault) ?? (configured.length === 1 ? configured[0] : undefined);
151
+ if (fallback)
152
+ names.add(fallback.name);
153
+ }
154
+ return [...names];
155
+ }
156
+ readProjectEnvBinding() {
157
+ if (!existsSync(this.paths.projectEnvPath))
158
+ return undefined;
159
+ const name = readFileSync(this.paths.projectEnvPath, "utf8").trim();
160
+ return name || undefined;
161
+ }
162
+ readProjectEnvsFile() {
163
+ if (!existsSync(this.paths.projectEnvsPath))
164
+ return [];
165
+ return readFileSync(this.paths.projectEnvsPath, "utf8")
166
+ .split(/\r?\n/u)
167
+ .map((line) => line.replace(/#.*$/u, "").trim())
168
+ .filter((line) => line !== "");
169
+ }
170
+ /** Declare another environment as usable by this project (cross-env approval). */
171
+ allowProjectEnvironment(name) {
172
+ if (!this.readConfig().environments[name]) {
173
+ throw new ConfigurationError(`unknown environment "${name}"; available: ${this.availableEnvironmentNames()}`);
174
+ }
175
+ const names = [...new Set([...this.readProjectEnvsFile(), name])].sort();
176
+ this.atomicWrite(this.paths.projectEnvsPath, names.join("\n") + "\n", 0o644);
129
177
  }
130
178
  readCredential(name) {
131
179
  const inline = process.env.OMC_CLOUDPIVOT_CREDENTIAL;
@@ -161,20 +209,25 @@ export class ConfigStore {
161
209
  }
162
210
  /** Available environment names, for error messages — stops agents guessing or spelunking ~/.config. */
163
211
  availableEnvironmentNames() {
164
- return Object.keys(this.readConfig().environments).join("、") || "(none configured; run omc setup)";
212
+ return this.ownEnvironmentNames().join("、") || "(none configured for this project; run omc setup)";
165
213
  }
166
214
  /** Resolve the active environment for a call. */
167
215
  resolveEnvironment(explicit) {
216
+ const resolved = this.resolveCandidate(explicit);
217
+ this.assertProjectScope(resolved.name, resolved.source);
218
+ return resolved;
219
+ }
220
+ resolveCandidate(explicit) {
168
221
  if (explicit && explicit.trim()) {
169
222
  const profile = this.readConfig().environments[explicit];
170
223
  if (!profile)
171
- throw new ConfigurationError(`unknown environment "${explicit}"; available: ${this.availableEnvironmentNames()} (doctor prints the bound 环境=; do not guess or read ~/.config/omc manually)`);
224
+ throw new ConfigurationError(`unknown environment "${explicit}"; available: ${this.availableEnvironmentNames()}(仅本项目;doctor prints the bound 环境=; do not guess or read ~/.config/omc manually)`);
172
225
  return { name: explicit, profile, source: "explicit" };
173
226
  }
174
227
  if (process.env.OMC_ENV) {
175
228
  const profile = this.readConfig().environments[process.env.OMC_ENV];
176
229
  if (!profile)
177
- throw new ConfigurationError(`OMC_ENV=${process.env.OMC_ENV} is not configured; available: ${this.availableEnvironmentNames()}`);
230
+ throw new ConfigurationError(`OMC_ENV=${process.env.OMC_ENV} is not configured; available: ${this.availableEnvironmentNames()}(仅本项目)`);
178
231
  return { name: process.env.OMC_ENV, profile, source: "OMC_ENV" };
179
232
  }
180
233
  if (existsSync(this.paths.projectEnvPath)) {
@@ -182,15 +235,36 @@ export class ConfigStore {
182
235
  if (name) {
183
236
  const profile = this.readConfig().environments[name];
184
237
  if (!profile)
185
- throw new ConfigurationError(`project .omc-env binds "${name}" which is not configured in omc.config.json; available: ${this.availableEnvironmentNames()} (run omc setup)`);
238
+ throw new ConfigurationError(`project .omc-env binds "${name}" which is not configured in omc.config.json; available: ${this.availableEnvironmentNames()}(仅本项目;run omc setup)`);
186
239
  return { name, profile, source: ".omc-env" };
187
240
  }
188
241
  }
189
- const entry = this.listEnvironments().find((item) => item.profile.isDefault);
242
+ const configured = this.listEnvironments({ all: true });
243
+ const entry = configured.find((item) => item.profile.isDefault) ?? (configured.length === 1 ? configured[0] : undefined);
190
244
  if (!entry)
191
245
  throw new ConfigurationError("no default environment; run omc setup or pass environment explicitly");
192
246
  return { name: entry.name, profile: entry.profile, source: "default" };
193
247
  }
248
+ /**
249
+ * Project isolation gate. A directory may hold several environments, but only
250
+ * those it declares (`.omc-envs` + `.omc-env`). Touching any other environment
251
+ * is a cross-project read: refuse BEFORE credentials/token/network, and tell
252
+ * the caller to obtain the user's approval. `OMC_ALLOW_CROSS_ENV=1` is the
253
+ * human-approved one-off escape hatch and announces itself on stderr.
254
+ */
255
+ assertProjectScope(name, source) {
256
+ const own = this.ownEnvironmentNames();
257
+ if (own.includes(name))
258
+ return;
259
+ if (process.env.OMC_ALLOW_CROSS_ENV === "1") {
260
+ console.error(`cross-env: 环境 "${name}" 不在本项目允许列表(${own.join("、") || "空"});已按 OMC_ALLOW_CROSS_ENV=1 放行本次访问。请确认这是用户批准的临时跨环境操作。`);
261
+ return;
262
+ }
263
+ throw new ConfigurationError(`跨环境访问被拒绝:环境 "${name}" 不属于本项目(本项目允许:${own.join("、") || "(none)"})。` +
264
+ `每个项目目录只能使用自己声明的环境,不允许读取其他环境的信息。` +
265
+ `如确需跨环境,先向用户报告并取得同意,再由用户执行 \`omc env allow ${name}\`(写入项目 .omc-envs),` +
266
+ `或临时设置 OMC_ALLOW_CROSS_ENV=1 单次放行。`);
267
+ }
194
268
  bindProjectEnvironment(name) {
195
269
  const config = this.readConfig();
196
270
  if (!config.environments[name])
@@ -343,6 +343,100 @@ function childTableColumns(parentCode, children) {
343
343
  * layered back from the previous draft. Child-table (201) controls are skipped:
344
344
  * their columns are regenerated from the model's subSchema.
345
345
  */
346
+ /**
347
+ * Options that carry an expression the DESIGNER computed (formula, condition,
348
+ * default formula). A rebuild re-scaffolds controls from field metadata, which
349
+ * cannot reproduce them: the scaffold only knows the field family. Losing one
350
+ * is silent — the control still renders, the page still publishes, and the only
351
+ * signal is an empty string where an expression used to be. Live 2026-10-21
352
+ * (customer 8.2.96, 季度考评): `field.update cascadeRepublish:true` rebuilt the
353
+ * form and `totalScore`/`score`/`totalEvaluationScore` came back with
354
+ * `shortTextStitch: ""` while the pre-rebuild backup still had
355
+ * `SUM({evaluation_form.realScore})`.
356
+ */
357
+ const FORMULA_BEARING_CONTROL_KEYS = [
358
+ "shortTextStitch",
359
+ "formulaConfig",
360
+ "displayFormula",
361
+ "requiredFormula",
362
+ "readonlyFormula",
363
+ "readonlyCondition",
364
+ "verifyFormula",
365
+ "computeFormula",
366
+ ];
367
+ function nonEmptyExpression(value) {
368
+ if (typeof value === "string")
369
+ return value.trim() !== "" && value.trim() !== "{}" && value.trim() !== "[]";
370
+ if (value && typeof value === "object" && !Array.isArray(value))
371
+ return Object.keys(value).length > 0;
372
+ return false;
373
+ }
374
+ /**
375
+ * Layer two attributes faces (base = published, over = draft). Controls keep
376
+ * their own `options` merged per key, so a control edited on only one face
377
+ * still contributes its untouched options to the rebuild baseline. The draft
378
+ * wins wherever both define a key.
379
+ */
380
+ function mergeAttributeFaces(base, over) {
381
+ if (base === undefined)
382
+ return over;
383
+ if (over === undefined)
384
+ return base;
385
+ const merged = { ...base };
386
+ for (const [code, control] of Object.entries(over)) {
387
+ const prior = base[code];
388
+ const options = control?.options;
389
+ if (prior && typeof prior === "object" && options && typeof options === "object" && !Array.isArray(options)
390
+ && prior.options && typeof prior.options === "object" && !Array.isArray(prior.options)) {
391
+ merged[code] = { ...control, options: { ...prior.options, ...options } };
392
+ continue;
393
+ }
394
+ merged[code] = control;
395
+ }
396
+ return merged;
397
+ }
398
+ /** controlCode → option keys that carried a real expression on that face. */
399
+ export function formulaBearingControlOptions(face) {
400
+ const carry = new Map();
401
+ for (const [code, control] of Object.entries(face)) {
402
+ const options = control?.options;
403
+ if (!options || typeof options !== "object" || Array.isArray(options))
404
+ continue;
405
+ const keys = FORMULA_BEARING_CONTROL_KEYS.filter((key) => nonEmptyExpression(options[key]));
406
+ if (keys.length > 0)
407
+ carry.set(code, keys);
408
+ }
409
+ return carry;
410
+ }
411
+ /**
412
+ * Fail-closed guard for the rebuild path: every expression present on the
413
+ * baseline face must still be present afterwards. Refusing beats shipping a
414
+ * form whose formula silently became "". The caller can restore from a backup
415
+ * artifact or pass the layout explicitly.
416
+ */
417
+ export function assertFormulasSurvivedRebuild(next, baseline, where) {
418
+ const lost = [];
419
+ for (const [code, optionKeys] of formulaBearingControlOptions(baseline)) {
420
+ const options = next[code]?.options;
421
+ const current = options && typeof options === "object" && !Array.isArray(options) ? options : {};
422
+ for (const key of optionKeys)
423
+ if (!nonEmptyExpression(current[key]))
424
+ lost.push({ control: code, option: key });
425
+ }
426
+ if (lost.length === 0)
427
+ return;
428
+ throw new ToolRefusalError({
429
+ status: "refused",
430
+ blocker: "form-rebuild-formula-would-be-lost",
431
+ schemaCode: where.schemaCode,
432
+ sheetCode: where.sheetCode,
433
+ losing: lost,
434
+ message: `重建表单会清空 ${lost.length} 处设计器公式(${lost.map((item) => `${item.control}.${item.option}`).join("、")})——` +
435
+ `脚手架只能按字段元数据重建控件,公式/条件不在字段上。写前零 wire 拒绝,避免「回读 published:true 但公式已空」。` +
436
+ `先取备份(form.get --full 或表单版本历史 /api/api/app/bizsheet/history/list),或显式传 draftAttributesJson 原布局;` +
437
+ `只改字段时不要用 cascadeRepublish。`,
438
+ });
439
+ }
346
440
  export function preserveDesignerControlOptions(next, previous) {
347
441
  const STRUCTURAL = new Set(["name", "name_i18n", "schemaCode", "displayField", "queryCode", "relativePropertyMatchCode"]);
348
442
  const preserved = [];
@@ -692,9 +786,21 @@ export async function executeFormDraftWrite(session, args) {
692
786
  // control visibility/width, label tweaks) are not derivable from field
693
787
  // metadata. A rebuild re-scaffolds, so capture the previous draft first and
694
788
  // layer those edits back on (see preserveDesignerControlOptions).
695
- const previousAttributes = !args.rebuild || typeof pre?.draftAttributesJson !== "string"
789
+ // Baseline for the preserve/guard step. The DRAFT is the normal source, but a
790
+ // published form whose draft face is empty/stale is not a safe baseline: the
791
+ // designer's formulas then live only on publishedAttributesJson, and reading
792
+ // the draft alone makes a rebuild look like "nothing to preserve" while it
793
+ // drops every expression (live 2026-10-21). Merge draft-over-published so a
794
+ // control the designer edited only on one face still carries its options.
795
+ const draftAttributes = !args.rebuild || typeof pre?.draftAttributesJson !== "string"
696
796
  ? undefined
697
797
  : decodeJsonObjectColumn(pre.draftAttributesJson, { invalid: "undefined", array: "reject", object: "accept" });
798
+ const publishedAttributes = !args.rebuild || typeof pre?.publishedAttributesJson !== "string"
799
+ ? undefined
800
+ : decodeJsonObjectColumn(pre.publishedAttributesJson, { invalid: "undefined", array: "reject", object: "accept" });
801
+ const previousAttributes = draftAttributes === undefined && publishedAttributes === undefined
802
+ ? undefined
803
+ : mergeAttributeFaces(publishedAttributes, draftAttributes);
698
804
  // `rebuild: true` discards the existing draft layout and re-scaffolds from
699
805
  // the published fields, so fields added after the form was first built (a
700
806
  // child table, a new relation) land in the layout. The platform designer's
@@ -741,6 +847,15 @@ export async function executeFormDraftWrite(session, args) {
741
847
  // controls verbatim, so a later field.update never propagated (bench20).
742
848
  attributes = syncFieldDerivedControlOptions(attributes, publishedFields);
743
849
  }
850
+ // Fail-closed: a rebuild that would drop a designer formula must never reach
851
+ // the wire. The merge above normally carries them, but it cannot when the
852
+ // control was not re-scaffolded (field family without a control shape) or the
853
+ // baseline face was unreadable — those are exactly the silent-loss cases.
854
+ if (args.rebuild === true && previousAttributes !== undefined && typeof attributes === "string") {
855
+ const composed = decodeJsonObjectColumn(attributes, { invalid: "undefined", array: "reject", object: "accept" });
856
+ if (composed !== undefined)
857
+ assertFormulasSurvivedRebuild(composed, previousAttributes, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
858
+ }
744
859
  const payload = {
745
860
  ...(pre ?? {}),
746
861
  schemaCode: args.schemaCode,
@@ -93,7 +93,13 @@ export function registerMetaLane(tools) {
93
93
  writeRequiresConfirmation: requiresProductionConfirmation(resolved.profile),
94
94
  removedConfigKeys: removedProfileKeys(resolved.profile),
95
95
  },
96
+ // Project-scoped: never advertise another directory's environments.
96
97
  configuredEnvironments: environments.map((e) => e.name),
98
+ environmentScope: {
99
+ project: store.configPaths.projectEnvPath.replace(/[\\/][^\\/]+$/u, ""),
100
+ own: store.ownEnvironmentNames(),
101
+ policy: "本目录只能使用 .omc-envs / .omc-env 声明的环境;跨环境需先向用户报告并取得同意(omc env allow <name>)",
102
+ },
97
103
  credentialsMode: store.credentialsMode(),
98
104
  session: sessionState,
99
105
  coverage: { total: ctx.manifest.items.length, byStatus: statusCounts },
@@ -78,7 +78,7 @@ export const ONLINE_JS_LANE_CONTRACT = Object.freeze({
78
78
  }),
79
79
  "onlinejs.compose": Object.freeze({
80
80
  scope: ONLINE_JS_SCOPE,
81
- description: `Compose the Online JS source as the customScript element into the form's draftHtmlJson layout array (then publish, promoting it verbatim to publishedHtmlJson). Core contract: preserve the platform template and only extend customScript.innerHTML — the six-element skeleton includes the ${PLATFORM_LIFECYCLE_WRAPPER_MARKER} lifecycle wrapper; the style, toolbar, template, beforeCustomScript, customScript, and afterCustomScript elements are kept or synthesized with platform defaults, while every layout/control element is preserved. customScript is rebuilt canonically as {tagName:'script', id:'customScript', innerHTML:<platform lifecycle wrapper containing source>, attrs:[]} because the designer requires an ARRAY attrs (e.attrs.some) and tagName, else the Online JS editor fails with t.some is not a function. Drives bizsheet/update + bizsheet/publish; publishedHtmlJson is read back verbatim and SHA-256-fingerprinted so the caller can verify byte equality. Read the EXISTING script first with onlinejs.get (decide replace vs INCREMENTAL from what is actually stored). Never overwrites the layout with a standalone script object or a partial [customScript]-only array (regression for issue 11's root cause).`,
81
+ description: `Compose the Online JS source as the customScript element into the form's draftHtmlJson layout array (then publish, promoting it verbatim to publishedHtmlJson). Core contract: preserve the platform template and only extend customScript.innerHTML — the six-element skeleton includes the ${PLATFORM_LIFECYCLE_WRAPPER_MARKER} lifecycle wrapper; the style, toolbar, template, beforeCustomScript, customScript, and afterCustomScript elements are kept or synthesized with platform defaults, while every layout/control element is preserved. customScript is rebuilt canonically as {tagName:'script', id:'customScript', innerHTML:<platform lifecycle wrapper containing source>, attrs:[]} because the designer requires an ARRAY attrs (e.attrs.some) and tagName, else the Online JS editor fails with t.some is not a function. Drives bizsheet/update + bizsheet/publish; publishedHtmlJson is read back verbatim and SHA-256-fingerprinted so the caller can verify byte equality. Read the EXISTING script first with onlinejs.get (decide replace vs INCREMENTAL from what is actually stored). Never overwrites the layout with a standalone script object or a partial [customScript]-only array (regression for issue 11's root cause). summary:true answers "did it land?" without any HTML/JS body (sha + byte counts + draftPublishedInSync); the default readback dedupes the composed draft and the script when they equal the published face, so the same script is never returned three times.`,
82
82
  }),
83
83
  }),
84
84
  });
@@ -101,6 +101,7 @@ export function registerOnlineJsLane(tools) {
101
101
  source: z.string().min(1).describe("在线 JS 代码。默认 INCREMENTAL:并入平台 (function(form){…}) 模板的既有脚本,模板与既有注册全部保留;配 replace:true 才是整段替换。脚本不得使用 export/import;事件名必须是平台事件(onLoad/onRendered/onValidate/onPreAction/onCustomAction/onActionDone/onPrintFormDataHandle/onPrintWorkflowLogDataHandle)"),
102
102
  replace: z.boolean().optional().describe("true = 以 source 整体替换 customScript(含完整 (function(form){…}) 脚本)。默认 false = 增量并入既有脚本,保留平台模板与既有逻辑"),
103
103
  publish: z.boolean().optional(),
104
+ summary: z.boolean().optional().describe("true = 只回同步状态(sha/字节数/是否同步/渲染兼容),不回任何 HTML/JS 原文。确认「改动是否生效」用这个;要看原文用 onlinejs.get"),
104
105
  }, async (session, args) => {
105
106
  const shouldPublish = args.publish !== false;
106
107
  // 1. Read the existing draftHtmlJson.
@@ -159,17 +160,56 @@ export function registerOnlineJsLane(tools) {
159
160
  const publishedElements = parseLayoutElements(publishedRaw);
160
161
  // 8. SHA-256 of the verbatim content.
161
162
  const publishedSha256 = createHash("sha256").update(publishedRaw).digest("hex");
163
+ const draftSha256 = createHash("sha256").update(composedDraftHtml).digest("hex");
164
+ const publishedScript = readCustomScriptSource(publishedRaw).script;
165
+ const customScriptSha256 = createHash("sha256").update(composedScript, "utf8").digest("hex");
166
+ const draftPublishedInSync = publishedScript !== null && publishedScript === composedScript;
167
+ const htmlRenderCompat = { draft: validateHtmlRender(composedDraftHtml, true), published: validateHtmlRender(publishedRaw, true) };
168
+ // Live 2026-10-21 (customer): this readback shipped the same ~10KB script
169
+ // three times (composedDraftHtmlJson + publishedHtmlJson + customScript),
170
+ // ~45KB for a call whose only question was "did publish land?" — the
171
+ // session's single largest context amplification. Answer that question
172
+ // directly and keep 原文 opt-in.
173
+ if (args.summary === true) {
174
+ return {
175
+ performed: { errcode: 0, published },
176
+ readback: {
177
+ publishedSha256,
178
+ draftSha256,
179
+ draftPublishedInSync,
180
+ publishedElementCount: publishedElements.length,
181
+ scriptElementPresent: publishedElements.some((element) => element.id === "customScript"),
182
+ customScriptMerged: args.replace !== true,
183
+ customScriptSha256,
184
+ customScriptBytes: Buffer.byteLength(composedScript, "utf8"),
185
+ composedDraftBytes: Buffer.byteLength(composedDraftHtml, "utf8"),
186
+ publishedHtmlBytes: Buffer.byteLength(publishedRaw, "utf8"),
187
+ htmlRenderCompat,
188
+ hint: "summary 模式不回原文;要脚本原文用 onlinejs.get,要完整布局用 artifact 或 form.get --full",
189
+ },
190
+ };
191
+ }
162
192
  return {
163
193
  performed: { errcode: 0, published },
164
194
  readback: {
195
+ // Dedupe: after a successful publish the draft and the published
196
+ // face are the same bytes, and the script also rides inside them.
197
+ // Return the composed payload ONCE (composedDraftHtmlJson keeps the
198
+ // field every consumer already reads) and state the equality instead
199
+ // of repeating ~11KB + ~10KB.
200
+ ...(composedDraftHtml === publishedRaw
201
+ ? { draftEqualsPublished: true }
202
+ : { publishedHtmlJson: publishedRaw }),
165
203
  composedDraftHtmlJson: composedDraftHtml,
166
- publishedHtmlJson: publishedRaw,
167
204
  publishedSha256,
205
+ draftSha256,
206
+ draftPublishedInSync,
168
207
  publishedElementCount: publishedElements.length,
169
208
  scriptElementPresent: publishedElements.some((element) => element.id === "customScript"),
170
209
  customScript: composedScript,
210
+ customScriptMatchesPublished: draftPublishedInSync,
171
211
  customScriptMerged: args.replace !== true,
172
- htmlRenderCompat: { draft: validateHtmlRender(composedDraftHtml, true), published: validateHtmlRender(publishedRaw, true) },
212
+ htmlRenderCompat,
173
213
  },
174
214
  };
175
215
  },
@@ -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
@@ -144,7 +144,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
144
144
  }),
145
145
  "org.role.list": Object.freeze({
146
146
  scope: "workflow participant resolution",
147
- description: "列出组织角色(`GET /api/api/organization/role/list`),扁平化为 {id,name,code,roleType,corpId}。用于把审批节点的参与者解析成平台认识的引用。**真实参与者语法**(2026-09-23 生产观察):`participant` 只能是 (a) 关键字 `{Originator}`;(b) 参与者对象数组 JSON `[{\"code\":\"<roleCode>\",\"corpId\":\"<corpId|other>\",\"id\":\"<roleId>\",\"name\":\"<角色名>\",\"type\":2}]`(type 2=角色,3=用户;多个用 `+` 连接);(c) 函数表达式如 `FindUserByRole({Originator.OU},\"<roleCode>\",\"random\")`。**自由文本角色名(如 `部门负责人`)能 publish 但解析不到人、不生成待办**——先用本工具拿到 `code`/`id` 再构造对象数组。",
147
+ description: "列出组织角色(`GET /api/api/organization/role/list`),递归展开角色组,返回 {count, roleCount, groupCount, roles[{id,name,code,roleType,corpId,group}], groups[{id,name,code,path,childCount}]}。**`count` 只数角色,不含角色组**——角色组是容器(平台行标记 `group:true`),旧版把空组当角色列出,曾让调用方把「19 个角色组」误读成「目标角色未创建」;`groups[].childCount` 才说明组内是否真的没有角色。用于把审批节点的参与者解析成平台认识的引用。**真实参与者语法**(2026-09-23 生产观察):`participant` 只能是 (a) 关键字 `{Originator}`;(b) 参与者对象数组 JSON `[{\"code\":\"<roleCode>\",\"corpId\":\"<corpId|other>\",\"id\":\"<roleId>\",\"name\":\"<角色名>\",\"type\":2}]`(type 2=角色,3=用户;多个用 `+` 连接);(c) 函数表达式如 `FindUserByRole({Originator.OU},\"<roleCode>\",\"random\")`。**自由文本角色名(如 `部门负责人`)能 publish 但解析不到人、不生成待办**——先用本工具拿到 `code`/`id` 再构造对象数组。",
148
148
  }),
149
149
  "org.department.list": Object.freeze({
150
150
  scope: "workflow participant resolution",
@@ -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;
@@ -2302,16 +2316,7 @@ export function registerWorkflowLane(tools) {
2302
2316
  inputSchema: { environment: environmentArg, capability: capabilityArg },
2303
2317
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
2304
2318
  const data = (await wireCall(session, { path: ENDPOINTS.organizationRoleList })).data;
2305
- const roles = [];
2306
- for (const group of wireRows(data)) {
2307
- const children = Array.isArray(group.children) ? group.children : [];
2308
- if (children.length === 0)
2309
- roles.push(roleOf(group, null));
2310
- else
2311
- for (const child of children)
2312
- roles.push(roleOf(child, String(group.name ?? "")));
2313
- }
2314
- return { count: roles.length, roles };
2319
+ return flattenRoleTree(wireRows(data));
2315
2320
  }),
2316
2321
  }));
2317
2322
  tools.push(defineTool({
@@ -2381,6 +2386,74 @@ export function registerWorkflowLane(tools) {
2381
2386
  }));
2382
2387
  }
2383
2388
  /** Flatten a role-group/role row into the participant-construction fields. */
2389
+ /**
2390
+ * Role groups are CONTAINERS, not roles. The platform's own row marks them
2391
+ * `group:true` (children carry `group:false`), so membership must be decided by
2392
+ * that flag — never by `children.length`. Live 2026-10-21 (customer 8.2.96,
2393
+ * 季度考评): every entry was a group with `children:[]`, so the old
2394
+ * `children.length === 0 ? role : …` shape returned 19 "roles" that were all
2395
+ * groups; the agent read `count:19` as "the 6 roles were never created" and
2396
+ * reported a false blocker to the user. Groups also nest, so flatten
2397
+ * recursively and keep them in a separate `groups` list.
2398
+ */
2399
+ const ROLE_CHILD_KEYS = ["children", "roles", "childRoles", "childList", "list"];
2400
+ function roleChildren(row) {
2401
+ for (const key of ROLE_CHILD_KEYS) {
2402
+ const value = row[key];
2403
+ if (Array.isArray(value))
2404
+ return value;
2405
+ }
2406
+ return [];
2407
+ }
2408
+ function isRoleGroup(row) {
2409
+ if (row.group === true)
2410
+ return true;
2411
+ // Older builds omit `group`; a container with children is still a container.
2412
+ // A row that carries `groupId` is a leaf role even if it has an empty array.
2413
+ if (row.groupId !== undefined && row.groupId !== null)
2414
+ return false;
2415
+ return roleChildren(row).length > 0;
2416
+ }
2417
+ export function flattenRoleTree(rows) {
2418
+ const roles = [];
2419
+ const groups = [];
2420
+ const walk = (nodes, containerPath) => {
2421
+ for (const node of nodes) {
2422
+ if (!isRoleGroup(node)) {
2423
+ roles.push(roleOf(node, containerPath.length > 0 ? containerPath[containerPath.length - 1] : null));
2424
+ continue;
2425
+ }
2426
+ const name = String(node.name ?? "");
2427
+ const path = [...containerPath, name];
2428
+ const children = roleChildren(node);
2429
+ groups.push({
2430
+ id: node.id ?? null,
2431
+ name: node.name ?? null,
2432
+ code: node.code ?? null,
2433
+ corpId: node.corpId ?? null,
2434
+ path: path.join(" / "),
2435
+ childCount: children.length,
2436
+ });
2437
+ if (children.length > 0)
2438
+ walk(children, path);
2439
+ }
2440
+ };
2441
+ walk(rows, []);
2442
+ return {
2443
+ // `count` stays role-only: it is what participant resolution consumes, and
2444
+ // counting containers as roles is exactly the misread this fixes.
2445
+ count: roles.length,
2446
+ roleCount: roles.length,
2447
+ groupCount: groups.length,
2448
+ roles,
2449
+ groups,
2450
+ ...(roles.length === 0
2451
+ ? {
2452
+ hint: "本次返回里没有任何角色行(只有角色组或无数据)。角色组不是角色:不要据此断言「角色未创建」。若预期有角色,先看 groups[].childCount —— 组内为空说明该组下确实未建角色;若要验证某个 roleCode 在运行期可用,用业务数据反证(如 data.list 读取引用该 roleCode 的记录,或 org.user.search 找到人后用其角色字段)。",
2453
+ }
2454
+ : {}),
2455
+ };
2456
+ }
2384
2457
  function roleOf(row, group) {
2385
2458
  return {
2386
2459
  id: row.id ?? null,
@@ -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.70",
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.70",
53
+ "@stalfh233/omc-fake-platform": "0.4.72",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.70",
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` / 两跳路径都不支持。
@@ -274,7 +276,7 @@ BIZ_ACTION:
274
276
  - 直接执行规则:`useLocation: ROW` + `operateType: WITH_OUT_VIEW`。
275
277
  - `WITH_VIEW` 是打开表单。
276
278
  - FLOW 按钮需要 workflowCode;CLI 会补安全的 actionConfig。
277
- - **在线 JS 分两条**:本 CLI 只做**读写与发布**——**读现存脚本用 `onlinejs.get`**(抽 customScript innerHTML 原文+SHA-256,不必拉 form.get --full 六份 JSON;据此决定 replace 还是默认 INCREMENTAL 并入),用 `onlinejs.compose` 把已定稿的 `source` 组合进平台 skeleton 的 `customScript` 再 publish,回读 `publishedHtmlJson`+SHA-256。**脚本内容的编写/增量合并不在本 CLI**:若当前项目自带在线 JS 编写规范(例如其 `.cpdocs`/技能或项目文档),遵循它;否则向用户/设计者索取已定稿的 `source`。不要在 omc 侧手写或臆造 JS 逻辑。
279
+ - **在线 JS 分两条**:本 CLI 只做**读写与发布**——**读现存脚本用 `onlinejs.get`**(抽 customScript innerHTML 原文+SHA-256,不必拉 form.get --full 六份 JSON;据此决定 replace 还是默认 INCREMENTAL 并入),用 `onlinejs.compose` 把已定稿的 `source` 组合进平台 skeleton 的 `customScript` 再 publish,回读 `publishedHtmlJson`+SHA-256。**确认是否落地用 `summary:true`**(只回 sha/字节数/`draftPublishedInSync`,不回原文,约 600B;默认回显也已去重,不再把同一段脚本回三次)。**脚本内容的编写/增量合并不在本 CLI**:若当前项目自带在线 JS 编写规范(例如其 `.cpdocs`/技能或项目文档),遵循它;否则向用户/设计者索取已定稿的 `source`。不要在 omc 侧手写或臆造 JS 逻辑。
278
280
 
279
281
  ## 运行验收与清理
280
282
 
@@ -164,7 +164,7 @@ you can wait for.
164
164
 
165
165
  视图与表单脚手架都从**发布面**读字段元数据——字段未发布,列/控件就生成不出来。
166
166
  `data.list` 对未配列的自定义字段投影为 null(显示面缺配置,不是数据缺失);核值用 `data.load`。
167
- 字段级变更(如关联展示字段)改完后,必须重走 `field.publish` → `form.draft` → `form.publish` 运行时才生效(表单控件快照字段 options);`field.update` 带 `"cascadeRepublish":true` 一调完成整条链(中途失败按 partial steps 如实报告),只刷新表单用 `form.publish rebuild:true`。
167
+ 字段级变更(如关联展示字段)改完后,必须重走 `field.publish` → `form.draft` → `form.publish` 运行时才生效(表单控件快照字段 options);`field.update` 带 `"cascadeRepublish":true` 一调完成整条链(中途失败按 partial steps 如实报告),只刷新表单用 `form.publish rebuild:true`。**重建会按字段元数据重生成控件,设计器公式(`shortTextStitch`/条件/默认值)不在字段上**:0.4.71 起写前校验,会丢公式即拒绝 `form-rebuild-formula-would-be-lost`;不需要重建布局时不要带此参数。
168
168
 
169
169
  ## Invariants
170
170
 
@@ -82,7 +82,20 @@ execute → readback/cleanup** (`omc-five-piece-flow`).
82
82
  There is no approval token, no `--yes`, and no self-approval switch. If a call
83
83
  refuses, work out which gate or boundary produced it from `omc-refusals`.
84
84
 
85
- ## 3. Boundaries
85
+ ## 3. Project environment isolation
86
+
87
+ Each project directory may use only the environments it declares: `.omc-env` (this
88
+ project's binding) and `.omc-envs` (its allowlist, one name per line). Any other
89
+ environment on the machine is another project's — `resolveEnvironment` refuses it
90
+ **before** credentials, token cache or network access, so nothing leaks. Lists
91
+ (`omc env list`, `get_status.configuredEnvironments`) are project-scoped too.
92
+
93
+ To use another environment you must ask the user first, then either
94
+ `omc env allow <name>` (writes `.omc-envs`, durable) or a one-off
95
+ `OMC_ALLOW_CROSS_ENV=1` (prints a `cross-env:` line to stderr). Never widen the
96
+ scope on your own initiative.
97
+
98
+ ## 4. Boundaries
86
99
 
87
100
  Out-of-coverage, blocked, or unsupported faces refuse with a manifest citation —
88
101
  that refusal is the answer, not an obstacle to route around with another tool.
@@ -34,7 +34,7 @@ omc help omc-write-hazards
34
34
  - 有映射时传非空 JSON 数组字符串,并在 draft/published 两面读回。
35
35
  - **运行时写关联值是另一种形状**(`data.save`/`data.update` 的 formData):单选关联(`propertyType 9`)传 **id 字符串或 `{ id }`**,传**数组会稳定 50000**;**多选关联(11)传分号分隔的 id 字符串 `"id1;id2"`(或 `{id:"id1;id2"}`),传数组稳定 50000**;**人员/组织(50/51/60/61)传 `[{"id","type","name"}]`,`type` 必填**(缺它稳定 50000)——`type` 是 UnitType 索引:**用户=3、部门=1**。用错形状只会拿到一个不透明的 50000。
36
36
  - **`mappings` 由表单运行时在用户选择时套用**,服务端 `data.save` **不会**自动回填映射目标字段。API 侧要自己写被映射字段,否则读回为空——别把它误判成“映射配置失败”。
37
- - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 field.update 的 cascadeRepublish 刷已带自定义布局/在线JS 的表单**(它会 rebuild 脚手架)。
37
+ - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 cascadeRepublish 刷带布局/在线JS 的表单**:rebuild 会清空设计器公式(已做写前拒绝)。
38
38
  - **运行时写值的最小形状**(组合出错只会得到一个不透明的 `50000`/NPE,逐一最小化再合):单选关联(9) → id 字符串 `"<id>"`;**多选关联(11) → 分号串 `"<id1>;<id2>"`**;**人员/组织(50/51/60/61) → `[{"id":"<id>","type":3,"name":"<名>"}]`,`type` 必填且为整数**(缺/非整数 → `BizField.parseFromClient` 拆箱 NPE→50000;用户=3、部门=1);日期(3) → 字符串 `"2026-09-30 10:00:00"`;子表(8) → 行对象数组 `[{"<子列code>":<值>, ...}]`。先各写一条最小行通过,再组合。
39
39
  - **DATE(3) 字段的 `options.format` 必须是平台大写枚举**(`YYYY-MM-DD`、`YYYY-MM-DD HH:mm:ss`、`YYYY年MM月DD日`…),**不是 Java 的 `yyyy-MM-dd`**:平台 `DateBizField` 按字面比较,Java 写法能创建字段但**首次运行时 save 稳定 `50000`(`不支持的format`)**(2026-09-30 R5 实测)。`field.create`/`intent` 会就地拒绝非枚举值。
40
40