@stalfh233/omc-cli 0.4.69 → 0.4.71
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/cli.js +23 -2
- package/dist/config-store.js +81 -7
- package/dist/endpoints.js +1 -0
- 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-lifecycle.js +20 -2
- package/dist/lanes/workflow-contract.js +1 -1
- package/dist/lanes/workflow.js +69 -10
- package/dist/rule-support.js +82 -1
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +1 -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/cli.js
CHANGED
|
@@ -968,12 +968,33 @@ async function main() {
|
|
|
968
968
|
command.command("setup").option("--list").option("--edit <environment>").option("--remove <environment>").action(async (options) => {
|
|
969
969
|
commandExitCode = await cmdSetup(new ConfigStore(defaultPaths()), [...(options.list ? ["--list"] : []), ...(options.edit ? ["--edit", options.edit] : []), ...(options.remove ? ["--remove", options.remove] : [])]);
|
|
970
970
|
});
|
|
971
|
-
command.command("env [action]").option("--json").action(async (action, options) => {
|
|
971
|
+
command.command("env [action] [name]").option("--json").action(async (action, name, options) => {
|
|
972
972
|
// Read-only inventory of configured environment names. r18: the sandbox
|
|
973
973
|
// guessed names instead of listing them; doctor's wording now points here.
|
|
974
974
|
const store = new ConfigStore(defaultPaths());
|
|
975
|
+
if (action === "allow") {
|
|
976
|
+
// The only sanctioned way to widen a project's environment scope. The
|
|
977
|
+
// list stays project-scoped until the operator names the environment —
|
|
978
|
+
// an agent cannot discover another project's environments from here.
|
|
979
|
+
const target = name;
|
|
980
|
+
if (!target) {
|
|
981
|
+
console.error("用法:omc env allow <环境名>(把该项目需要的另一环境写入 .omc-envs;执行前须向用户报告并取得同意)");
|
|
982
|
+
commandExitCode = CLI_EXIT_CODES.invalidInput;
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
store.allowProjectEnvironment(target);
|
|
987
|
+
console.error(`已允许本项目使用环境 ${target}(写入 .omc-envs)`);
|
|
988
|
+
commandExitCode = CLI_EXIT_CODES.ok;
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
console.error(error.message);
|
|
992
|
+
commandExitCode = CLI_EXIT_CODES.invalidInput;
|
|
993
|
+
}
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
975
996
|
if (action !== undefined && action !== "list") {
|
|
976
|
-
console.error("用法:omc env list [--json]");
|
|
997
|
+
console.error("用法:omc env list [--json] | omc env allow <环境名>");
|
|
977
998
|
commandExitCode = CLI_EXIT_CODES.invalidInput;
|
|
978
999
|
return;
|
|
979
1000
|
}
|
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/endpoints.js
CHANGED
|
@@ -55,6 +55,7 @@ export const ENDPOINTS = {
|
|
|
55
55
|
businessRuleCreate: "/api/api/app/businessrule/create",
|
|
56
56
|
businessRuleUpdate: "/api/api/app/businessrule/update",
|
|
57
57
|
businessRulePublish: "/api/api/app/businessrule/publish",
|
|
58
|
+
businessRuleNodeRunMap: "/api/api/app/businessrule/node_run_map",
|
|
58
59
|
businessRuleEnable: "/api/api/app/businessrule/enable",
|
|
59
60
|
businessRuleDelete: "/api/api/app/businessrule/delete",
|
|
60
61
|
dataRuleCreate: "/api/api/app/bizdatarule/create",
|
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
|
},
|
|
@@ -3,7 +3,7 @@ import { ENDPOINTS } from "../endpoints.js";
|
|
|
3
3
|
import { writeTool } from "../write.js";
|
|
4
4
|
import { registerPackageTool } from "../package-tool.js";
|
|
5
5
|
import { writeSummary } from "../human-summary.js";
|
|
6
|
-
import { executeRuleSaveWrite, ruleNodeGraphArg, assertRuleSchemaExists } from "../rule-support.js";
|
|
6
|
+
import { executeRuleSaveWrite, ruleNodeGraphArg, assertRuleSchemaExists, explainRuleSelfCheck } from "../rule-support.js";
|
|
7
7
|
import { ToolRefusalError } from "../tool-types.js";
|
|
8
8
|
import { prepareRuleGraph, validateRuleNodeGraph, PLATFORM_TRIGGER_RULE_CODES } from "./rule-graph.js";
|
|
9
9
|
import { wireCall, wireRows, WireError } from "../wire.js";
|
|
@@ -52,7 +52,25 @@ export async function executeRuleEnableWrite(session, args) {
|
|
|
52
52
|
const full = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id: String(row.id) } })).data;
|
|
53
53
|
if (!full)
|
|
54
54
|
throw new WireError("business", `rule ${args.ruleCode} 完整模型读回为空(businessrule/get id ${row.id}),拒绝用列表投影发布`, 302024);
|
|
55
|
-
|
|
55
|
+
let published;
|
|
56
|
+
try {
|
|
57
|
+
published = await wireCall(session, { path: ENDPOINTS.businessRulePublish, method: "POST", body: { ...full, enabled: true, status: "ENABLED" } });
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// 550018 is the platform's self-check refusal (see explainRuleSelfCheck).
|
|
61
|
+
if (error instanceof WireError && error.errcode === 550018) {
|
|
62
|
+
const detail = await explainRuleSelfCheck(session, { ...full, enabled: true, status: "ENABLED" });
|
|
63
|
+
throw new ToolRefusalError({
|
|
64
|
+
status: "refused",
|
|
65
|
+
blocker: "rule-self-check-failed",
|
|
66
|
+
schemaCode: args.schemaCode,
|
|
67
|
+
ruleCode: args.ruleCode,
|
|
68
|
+
...detail,
|
|
69
|
+
message: `规则 ${args.ruleCode} 启用时被平台自检拒绝(550018)。${detail.nodeChecks.length > 0 ? `问题节点:${JSON.stringify(detail.nodeChecks).slice(0, 600)}` : "先用 field.list(all:true) 核对引用的字段是否已发布。"}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
56
74
|
let enableErrcode = 0;
|
|
57
75
|
let alreadyEnabled = false;
|
|
58
76
|
try {
|
|
@@ -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
|
@@ -2302,16 +2302,7 @@ export function registerWorkflowLane(tools) {
|
|
|
2302
2302
|
inputSchema: { environment: environmentArg, capability: capabilityArg },
|
|
2303
2303
|
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
2304
2304
|
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 };
|
|
2305
|
+
return flattenRoleTree(wireRows(data));
|
|
2315
2306
|
}),
|
|
2316
2307
|
}));
|
|
2317
2308
|
tools.push(defineTool({
|
|
@@ -2381,6 +2372,74 @@ export function registerWorkflowLane(tools) {
|
|
|
2381
2372
|
}));
|
|
2382
2373
|
}
|
|
2383
2374
|
/** Flatten a role-group/role row into the participant-construction fields. */
|
|
2375
|
+
/**
|
|
2376
|
+
* Role groups are CONTAINERS, not roles. The platform's own row marks them
|
|
2377
|
+
* `group:true` (children carry `group:false`), so membership must be decided by
|
|
2378
|
+
* that flag — never by `children.length`. Live 2026-10-21 (customer 8.2.96,
|
|
2379
|
+
* 季度考评): every entry was a group with `children:[]`, so the old
|
|
2380
|
+
* `children.length === 0 ? role : …` shape returned 19 "roles" that were all
|
|
2381
|
+
* groups; the agent read `count:19` as "the 6 roles were never created" and
|
|
2382
|
+
* reported a false blocker to the user. Groups also nest, so flatten
|
|
2383
|
+
* recursively and keep them in a separate `groups` list.
|
|
2384
|
+
*/
|
|
2385
|
+
const ROLE_CHILD_KEYS = ["children", "roles", "childRoles", "childList", "list"];
|
|
2386
|
+
function roleChildren(row) {
|
|
2387
|
+
for (const key of ROLE_CHILD_KEYS) {
|
|
2388
|
+
const value = row[key];
|
|
2389
|
+
if (Array.isArray(value))
|
|
2390
|
+
return value;
|
|
2391
|
+
}
|
|
2392
|
+
return [];
|
|
2393
|
+
}
|
|
2394
|
+
function isRoleGroup(row) {
|
|
2395
|
+
if (row.group === true)
|
|
2396
|
+
return true;
|
|
2397
|
+
// Older builds omit `group`; a container with children is still a container.
|
|
2398
|
+
// A row that carries `groupId` is a leaf role even if it has an empty array.
|
|
2399
|
+
if (row.groupId !== undefined && row.groupId !== null)
|
|
2400
|
+
return false;
|
|
2401
|
+
return roleChildren(row).length > 0;
|
|
2402
|
+
}
|
|
2403
|
+
export function flattenRoleTree(rows) {
|
|
2404
|
+
const roles = [];
|
|
2405
|
+
const groups = [];
|
|
2406
|
+
const walk = (nodes, containerPath) => {
|
|
2407
|
+
for (const node of nodes) {
|
|
2408
|
+
if (!isRoleGroup(node)) {
|
|
2409
|
+
roles.push(roleOf(node, containerPath.length > 0 ? containerPath[containerPath.length - 1] : null));
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
2412
|
+
const name = String(node.name ?? "");
|
|
2413
|
+
const path = [...containerPath, name];
|
|
2414
|
+
const children = roleChildren(node);
|
|
2415
|
+
groups.push({
|
|
2416
|
+
id: node.id ?? null,
|
|
2417
|
+
name: node.name ?? null,
|
|
2418
|
+
code: node.code ?? null,
|
|
2419
|
+
corpId: node.corpId ?? null,
|
|
2420
|
+
path: path.join(" / "),
|
|
2421
|
+
childCount: children.length,
|
|
2422
|
+
});
|
|
2423
|
+
if (children.length > 0)
|
|
2424
|
+
walk(children, path);
|
|
2425
|
+
}
|
|
2426
|
+
};
|
|
2427
|
+
walk(rows, []);
|
|
2428
|
+
return {
|
|
2429
|
+
// `count` stays role-only: it is what participant resolution consumes, and
|
|
2430
|
+
// counting containers as roles is exactly the misread this fixes.
|
|
2431
|
+
count: roles.length,
|
|
2432
|
+
roleCount: roles.length,
|
|
2433
|
+
groupCount: groups.length,
|
|
2434
|
+
roles,
|
|
2435
|
+
groups,
|
|
2436
|
+
...(roles.length === 0
|
|
2437
|
+
? {
|
|
2438
|
+
hint: "本次返回里没有任何角色行(只有角色组或无数据)。角色组不是角色:不要据此断言「角色未创建」。若预期有角色,先看 groups[].childCount —— 组内为空说明该组下确实未建角色;若要验证某个 roleCode 在运行期可用,用业务数据反证(如 data.list 读取引用该 roleCode 的记录,或 org.user.search 找到人后用其角色字段)。",
|
|
2439
|
+
}
|
|
2440
|
+
: {}),
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2384
2443
|
function roleOf(row, group) {
|
|
2385
2444
|
return {
|
|
2386
2445
|
id: row.id ?? null,
|
package/dist/rule-support.js
CHANGED
|
@@ -463,6 +463,71 @@ export async function computeCarriedNodeCodes(session, schemaCode, ruleCode, pre
|
|
|
463
463
|
return undefined; // 取不到存量图 → 维持严格闭集,行为与放宽前一致
|
|
464
464
|
}
|
|
465
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* 550018 is the platform's catch-all "rule self-check failed" (ErrorCode
|
|
468
|
+
* BUSINESS_RULE_PARAM_ERROR from BusinessRuleFacadeImpl.publishBusinessRule when
|
|
469
|
+
* checkBizRuleIsEnable() is false). The message names nothing, and the real原因
|
|
470
|
+
* is one of: a referenced property that does not exist, a missing org object, or
|
|
471
|
+
* a dead cycle. `businessrule/node_run_map` (BusinessRuleController.getNodeRunMaps
|
|
472
|
+
* → checkBizRuleError) returns exactly that per node, so a refusal can name the
|
|
473
|
+
* offending node and field instead of asking the caller to open the designer.
|
|
474
|
+
*/
|
|
475
|
+
const RULE_SELF_CHECK_ERRCODE = 550018;
|
|
476
|
+
function ruleSelfCheckMessage(ruleCode, checks) {
|
|
477
|
+
if (checks.length === 0)
|
|
478
|
+
return `规则 ${ruleCode} 发布被平台自检拒绝(550018「保存规则失败,规则存在异常」),但 node_run_map 未给出具体节点——常见原因:引用的字段未发布/不存在、组织对象失效、或存在死循环。先用 field.list(all:true) 核对引用的字段是否已发布。`;
|
|
479
|
+
const detail = checks.map((check) => {
|
|
480
|
+
const parts = [];
|
|
481
|
+
const missing = check.missingProperties;
|
|
482
|
+
if (missing?.length)
|
|
483
|
+
parts.push(`字段不存在/未发布:${missing.map((item) => `${item.objectName ?? "?"}.${item.propertyName ?? item.propertyCode ?? "?"}`).join("、")}`);
|
|
484
|
+
if (check.missingOrgObjects)
|
|
485
|
+
parts.push("组织对象不存在");
|
|
486
|
+
if (check.deadCycles)
|
|
487
|
+
parts.push("存在死循环");
|
|
488
|
+
return `${String(check.nodeCode ?? "?")}${check.nodeName ? `(${String(check.nodeName)})` : ""} ${parts.join(";")}`;
|
|
489
|
+
});
|
|
490
|
+
return `规则 ${ruleCode} 发布被平台自检拒绝(550018):${detail.join(" | ")}。节点内引用了不存在或未发布的字段时,保存能过、发布必拒——先补字段并 field.publish,再重发。`;
|
|
491
|
+
}
|
|
492
|
+
export async function explainRuleSelfCheck(session, model) {
|
|
493
|
+
try {
|
|
494
|
+
const response = await wireCall(session, { path: ENDPOINTS.businessRuleNodeRunMap, method: "POST", body: model });
|
|
495
|
+
const rows = wireRows(response.data);
|
|
496
|
+
const nodeChecks = rows.flatMap((row) => {
|
|
497
|
+
// Wire shape (QuoteObjectVO): {objectCode, objectName, quotePropertyList:
|
|
498
|
+
// [{propertyCode, propertyName, exist, propertyPublished}]} — the property
|
|
499
|
+
// sits one level deeper than the object.
|
|
500
|
+
const missingProperties = Array.isArray(row.notExistPropertyObjectList)
|
|
501
|
+
? row.notExistPropertyObjectList.flatMap((object) => {
|
|
502
|
+
const properties = Array.isArray(object.quotePropertyList) ? object.quotePropertyList : [];
|
|
503
|
+
const broken = properties.filter((property) => property.exist === false || property.propertyPublished === false);
|
|
504
|
+
const source = broken.length > 0 ? broken : properties;
|
|
505
|
+
return source.map((property) => ({
|
|
506
|
+
objectName: object.objectName ?? object.objectCode ?? null,
|
|
507
|
+
propertyCode: property.propertyCode ?? null,
|
|
508
|
+
propertyName: property.propertyName ?? null,
|
|
509
|
+
published: property.propertyPublished ?? null,
|
|
510
|
+
}));
|
|
511
|
+
})
|
|
512
|
+
: [];
|
|
513
|
+
const missingOrgObjects = Array.isArray(row.notExistOrgObjectList) ? row.notExistOrgObjectList : [];
|
|
514
|
+
const deadCycles = Array.isArray(row.deadCycleRunMapList) ? row.deadCycleRunMapList : [];
|
|
515
|
+
if (missingProperties.length === 0 && missingOrgObjects.length === 0 && deadCycles.length === 0)
|
|
516
|
+
return [];
|
|
517
|
+
return [{
|
|
518
|
+
nodeCode: row.nodeCode ?? null,
|
|
519
|
+
nodeName: row.nodeName ?? null,
|
|
520
|
+
...(missingProperties.length > 0 ? { missingProperties } : {}),
|
|
521
|
+
...(missingOrgObjects.length > 0 ? { missingOrgObjects } : {}),
|
|
522
|
+
...(deadCycles.length > 0 ? { deadCycles } : {}),
|
|
523
|
+
}];
|
|
524
|
+
});
|
|
525
|
+
return { nodeChecks };
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return { nodeChecks: [] };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
466
531
|
export async function executeRuleSaveWrite(session, args) {
|
|
467
532
|
// Pre-flight dry lint (ticket 14): a mis-shaped graph must be refused BEFORE
|
|
468
533
|
// any wire call, with structured blockers locating the node/key — never
|
|
@@ -594,7 +659,23 @@ export async function executeRuleSaveWrite(session, args) {
|
|
|
594
659
|
// schedule and a TIMING_TASK rule never gets a quartz job (2026-10-06). Use
|
|
595
660
|
// the authoritative `businessrule/get?id` model (same as rule.get/enable).
|
|
596
661
|
const full = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id: String(row.id) } })).data;
|
|
597
|
-
|
|
662
|
+
try {
|
|
663
|
+
await wireCall(session, { path: ENDPOINTS.businessRulePublish, method: "POST", body: full ?? row });
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
if (error instanceof WireError && error.errcode === RULE_SELF_CHECK_ERRCODE) {
|
|
667
|
+
const detail = await explainRuleSelfCheck(session, (full ?? row));
|
|
668
|
+
throw new ToolRefusalError({
|
|
669
|
+
status: "refused",
|
|
670
|
+
blocker: "rule-self-check-failed",
|
|
671
|
+
schemaCode: args.schemaCode,
|
|
672
|
+
ruleCode: args.ruleCode,
|
|
673
|
+
...detail,
|
|
674
|
+
message: ruleSelfCheckMessage(args.ruleCode, detail.nodeChecks),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
598
679
|
}
|
|
599
680
|
const effective = await currentRow();
|
|
600
681
|
// Live 8.6.26 (2026-09-17): businessrule/publish mints a version but does
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stalfh233/omc-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.71",
|
|
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.71",
|
|
54
54
|
"commander": "14.0.3",
|
|
55
|
-
"omc-core": "0.4.
|
|
55
|
+
"omc-core": "0.4.71",
|
|
56
56
|
"proper-lockfile": "4.1.2",
|
|
57
57
|
"write-file-atomic": "6.0.0",
|
|
58
58
|
"zod": "^3.23.8",
|
|
@@ -274,7 +274,7 @@ BIZ_ACTION:
|
|
|
274
274
|
- 直接执行规则:`useLocation: ROW` + `operateType: WITH_OUT_VIEW`。
|
|
275
275
|
- `WITH_VIEW` 是打开表单。
|
|
276
276
|
- 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
|
|
277
|
+
- **在线 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
278
|
|
|
279
279
|
## 运行验收与清理
|
|
280
280
|
|
|
@@ -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
|
|