@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 +76 -1
- package/dist/bench/evals.js +8 -0
- package/dist/cli.js +68 -4
- package/dist/config-store.js +81 -7
- package/dist/form-support.js +116 -1
- package/dist/lanes/meta.js +6 -0
- package/dist/lanes/online-js-contract.js +1 -1
- package/dist/lanes/online-js.js +42 -2
- package/dist/lanes/rule-graph.js +61 -2
- package/dist/lanes/workflow-contract.js +1 -1
- package/dist/lanes/workflow.js +90 -17
- package/dist/rule-support.js +29 -3
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +3 -1
- package/skills/omc-config-draft-review.md +1 -1
- package/skills/omc-start-here.md +14 -1
- package/skills/omc-write-hazards.md +1 -1
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
|
package/dist/bench/evals.js
CHANGED
|
@@ -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")
|
|
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] : [])]); });
|
package/dist/config-store.js
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
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
|
|
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()}
|
|
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()}
|
|
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
|
|
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])
|
package/dist/form-support.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/lanes/meta.js
CHANGED
|
@@ -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
|
});
|
package/dist/lanes/online-js.js
CHANGED
|
@@ -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
|
|
212
|
+
htmlRenderCompat,
|
|
173
213
|
},
|
|
174
214
|
};
|
|
175
215
|
},
|
package/dist/lanes/rule-graph.js
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
package/dist/lanes/workflow.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/rule-support.js
CHANGED
|
@@ -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.
|
|
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.
|
|
53
|
+
"@stalfh233/omc-fake-platform": "0.4.72",
|
|
54
54
|
"commander": "14.0.3",
|
|
55
|
-
"omc-core": "0.4.
|
|
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
|
|
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
|
|
package/skills/omc-start-here.md
CHANGED
|
@@ -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.
|
|
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` → 回读核对。**别用
|
|
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
|
|