@stalfh233/omc-cli 0.6.17 → 0.6.19
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/batch-call.js +21 -2
- package/dist/cli-stdout.js +1 -0
- package/dist/cli.js +24 -14
- package/dist/field-change.js +7 -1
- package/dist/field-families.js +67 -2
- package/dist/graph.js +31 -7
- package/dist/lanes/app.js +17 -4
- package/dist/lanes/field.js +120 -5
- package/dist/lanes/folder.js +29 -2
- package/dist/lanes/model-contract.js +1 -1
- package/dist/lanes/model.js +51 -29
- package/dist/lanes/online-js-contract.js +1 -1
- package/dist/lanes/online-js.js +41 -9
- package/dist/lanes/programming.js +67 -14
- package/dist/lanes/runtime-data.js +153 -16
- package/dist/mcp-graph.js +32 -13
- package/dist/programming-workflow.js +16 -3
- package/dist/read-shapes.js +3 -3
- package/dist/residue.js +116 -0
- package/dist/rollback.js +54 -19
- package/dist/write.js +6 -2
- package/package.json +3 -3
package/dist/lanes/field.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ENDPOINTS } from "../endpoints.js";
|
|
3
3
|
import { fieldChangeWireBody, fieldUpdateWireBody, prepareFieldChange, prepareFieldUpdate } from "../field-change.js";
|
|
4
|
-
import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, assertFieldFamilyAllowed, assertSelectionChoicesAllowed, } from "../field-families.js";
|
|
4
|
+
import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, assertFieldFamilyAllowed, assertSelectionChoicesAllowed, assertRelationTargetExists, relationPublishFailureMessage, } from "../field-families.js";
|
|
5
5
|
import { capabilityArg, environmentArg } from "../args.js";
|
|
6
6
|
import { governedRead } from "../read.js";
|
|
7
7
|
import { writeTool } from "../write.js";
|
|
@@ -12,10 +12,35 @@ import { defineTool, ToolRefusalError } from "../tool-types.js";
|
|
|
12
12
|
import { wireCall, wireRows, WireError } from "../wire.js";
|
|
13
13
|
import { executeFormDraftWrite, executeFormPublishWrite, readFormOrNull } from "../form-support.js";
|
|
14
14
|
import { FIELD_LANE_CONTRACT } from "./field-contract.js";
|
|
15
|
-
import { mapBounded } from "../support.js";
|
|
15
|
+
import { flattenTreeRows, mapBounded } from "../support.js";
|
|
16
16
|
import { extractBizObjectPage } from "./runtime-data.js";
|
|
17
17
|
const FIELD_LANE = FIELD_LANE_CONTRACT.lane;
|
|
18
18
|
const FIELD_TOOLS = FIELD_LANE_CONTRACT.tools;
|
|
19
|
+
/** Read actual model schema codes; bare modelList may expose internal node ids. */
|
|
20
|
+
export async function modelSchemaCodes(session) {
|
|
21
|
+
try {
|
|
22
|
+
// Relation codes are tenant-global on the platform, not application-scoped.
|
|
23
|
+
// Enumerate every application for this call only; deliberately do not cache
|
|
24
|
+
// this merged candidate set across calls because model writes can change it.
|
|
25
|
+
const apps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data)
|
|
26
|
+
.map((row) => row.appCode ?? row.code)
|
|
27
|
+
.filter((code) => typeof code === "string" && code.length > 0);
|
|
28
|
+
const trees = await mapBounded(apps, 4, async (appCode) => flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode } })).data)));
|
|
29
|
+
const codes = trees.flat()
|
|
30
|
+
.filter((row) => row.type === "BizModel")
|
|
31
|
+
.map((row) => String(row.code ?? row.schemaCode ?? row.bizSchemaCode ?? ""))
|
|
32
|
+
.filter((code) => code.length > 0);
|
|
33
|
+
if (codes.length > 0)
|
|
34
|
+
return codes;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Fall through to the compatibility endpoint below when the tenant-wide
|
|
38
|
+
// discovery face is unavailable (older/fake transports).
|
|
39
|
+
}
|
|
40
|
+
return wireRows((await wireCall(session, { path: ENDPOINTS.modelList, query: {} })).data)
|
|
41
|
+
.map((row) => String(row.schemaCode ?? row.bizSchemaCode ?? row.code ?? ""))
|
|
42
|
+
.filter((code) => code.length > 0);
|
|
43
|
+
}
|
|
19
44
|
async function readFieldRollback(session, args) {
|
|
20
45
|
for (const published of [false, true]) {
|
|
21
46
|
const row = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: String(args.schemaCode), isPublish: String(published) } })).data).find((entry) => String(entry.code ?? entry.propertyCode) === String(args.code));
|
|
@@ -377,6 +402,8 @@ export function registerFieldLane(tools) {
|
|
|
377
402
|
// wire body (src/field-change.ts), so configure_model and intent share it.
|
|
378
403
|
assertNoSelfSchemaAggregation(args, published);
|
|
379
404
|
await assertNoChildSchemaAggregation(session, args.schemaCode, args.propertyType, args.options ?? {});
|
|
405
|
+
const models = await modelSchemaCodes(session);
|
|
406
|
+
assertRelationTargetExists({ ...args }, models);
|
|
380
407
|
// 目标版本决定 optionsSet 形状:8.2 以下要补 optionStructure/treeSelect
|
|
381
408
|
// (cpdocs issue #8:缺键让设计器选项弹窗硬崩)。
|
|
382
409
|
const targetVersion = session.detectedVersion();
|
|
@@ -434,6 +461,7 @@ export function registerFieldLane(tools) {
|
|
|
434
461
|
readback: {
|
|
435
462
|
face: "draft",
|
|
436
463
|
draftFieldPresent: rows.some((row) => (row.code ?? row.propertyCode) === args.code),
|
|
464
|
+
...(prepared.normalized ? { normalized: prepared.normalized } : {}),
|
|
437
465
|
...(textLike
|
|
438
466
|
? {
|
|
439
467
|
sideEffectsNote: "平台可能为文本/下拉字段自动生成一条 datarule type1 checkType=4(maxLength≈200);本调用未显式创建。清理/对账请用 datarule.list 核对,不要把自动行当成重复写入。",
|
|
@@ -637,6 +665,15 @@ async function loadFieldRowForUpdate(session, schemaCode, code) {
|
|
|
637
665
|
const record = rows.find((row) => (row.code ?? row.propertyCode) === code);
|
|
638
666
|
if (!record)
|
|
639
667
|
throw new WireError("business", `field ${code} not found on ${schemaCode}`, 301001);
|
|
668
|
+
// Some platform list faces are diet projections and omit the display name;
|
|
669
|
+
// update readback must still preserve the existing name/name_i18n.
|
|
670
|
+
try {
|
|
671
|
+
const detail = (await wireCall(session, { path: ENDPOINTS.fieldGet, query: { schemaCode, propertyCode: code } })).data;
|
|
672
|
+
return { ...detail, ...record, name: record.name ?? detail.name, name_i18n: record.name_i18n ?? detail.name_i18n };
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
// The detail face is optional; retain the list row.
|
|
676
|
+
}
|
|
640
677
|
return record;
|
|
641
678
|
}
|
|
642
679
|
async function countModelDataRows(session, schemaCode) {
|
|
@@ -1031,6 +1068,58 @@ async function runFieldUpdate(session, args) {
|
|
|
1031
1068
|
...(args.propertyEmpty !== undefined ? { propertyEmpty: args.propertyEmpty } : {}),
|
|
1032
1069
|
};
|
|
1033
1070
|
const prepared = prepareFieldUpdate(update);
|
|
1071
|
+
const currentOptions = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
|
|
1072
|
+
const requestedChanges = [];
|
|
1073
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
1074
|
+
if (args.name !== undefined && !same(record.name ?? null, prepared.name ?? args.name))
|
|
1075
|
+
requestedChanges.push("name");
|
|
1076
|
+
if (args.name_i18n !== undefined && !same(record.name_i18n ?? null, prepared.nameI18n ?? args.name_i18n))
|
|
1077
|
+
requestedChanges.push("name_i18n");
|
|
1078
|
+
if (args.relativeCode !== undefined && !same(record.relativeCode ?? null, args.relativeCode))
|
|
1079
|
+
requestedChanges.push("relativeCode");
|
|
1080
|
+
if (args.propertyEmpty !== undefined && !same(record.propertyEmpty ?? null, args.propertyEmpty))
|
|
1081
|
+
requestedChanges.push("propertyEmpty");
|
|
1082
|
+
if (args.options !== undefined) {
|
|
1083
|
+
for (const key of new Set([...Object.keys(currentOptions), ...Object.keys(prepared.options)]))
|
|
1084
|
+
if (!same(currentOptions[key] ?? null, prepared.options[key] ?? null))
|
|
1085
|
+
requestedChanges.push(`options.${key}`);
|
|
1086
|
+
}
|
|
1087
|
+
const mutableOptionKeys = new Set([
|
|
1088
|
+
// These are established field-row update surfaces (tickets 60/61 and the
|
|
1089
|
+
// condition pairing round). Everything else stays fail-closed: the
|
|
1090
|
+
// platform accepts many keys and silently drops them.
|
|
1091
|
+
"dictionaryData", "optionsSet", "mappings", "schemaCode", "queryCode", "displayField", "displayFieldType", "displayFieldFormat",
|
|
1092
|
+
"relativePropertyMatchCode", "relativePropertyCode", "linkMode", "showStyle", "searchFormula", "conditions",
|
|
1093
|
+
"requiredFormula", "readonlyCondition", "displayFormula", "readonlyFormula", "scale",
|
|
1094
|
+
]);
|
|
1095
|
+
const changedOptionKeys = requestedChanges.filter((field) => field.startsWith("options.")).map((field) => field.slice("options.".length));
|
|
1096
|
+
const immutableOptionKeys = changedOptionKeys.filter((key) => !mutableOptionKeys.has(key));
|
|
1097
|
+
if (immutableOptionKeys.length > 0) {
|
|
1098
|
+
throw new ToolRefusalError({
|
|
1099
|
+
status: "refused",
|
|
1100
|
+
blocker: "field-options-immutable",
|
|
1101
|
+
schemaCode: args.schemaCode,
|
|
1102
|
+
code: args.code,
|
|
1103
|
+
fields: immutableOptionKeys.map((key) => `options.${key}`),
|
|
1104
|
+
mutableFields: [...mutableOptionKeys].map((key) => `options.${key}`),
|
|
1105
|
+
message: `field.update 不允许经此面修改 options.${immutableOptionKeys.join("、")};这类字段配置会被平台静默丢弃。可改白名单:${[...mutableOptionKeys].map((key) => `options.${key}`).join("、")}。关联结构(schemaCode/queryCode/displayField 等)请 field.delete + field.create 重建;条件请用字段条件配置。`,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
if (args.choices !== undefined && !same(choiceCodesOf(currentOptions) ?? [], [...args.choices]))
|
|
1109
|
+
requestedChanges.push("choices");
|
|
1110
|
+
if (requestedChanges.length === 0) {
|
|
1111
|
+
return {
|
|
1112
|
+
performed: { errcode: 0, unchanged: true },
|
|
1113
|
+
readback: {
|
|
1114
|
+
face: "draft",
|
|
1115
|
+
unchanged: true,
|
|
1116
|
+
changedFields: [],
|
|
1117
|
+
...(record.name !== undefined ? { name: record.name } : {}),
|
|
1118
|
+
...(record.name_i18n !== undefined ? { name_i18n: record.name_i18n } : {}),
|
|
1119
|
+
availableFields: ["name", "name_i18n", "options", "choices", "relativeCode", "propertyEmpty"],
|
|
1120
|
+
},
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1034
1123
|
const performed = await wireCall(session, { path: ENDPOINTS.fieldUpdate, method: "POST", body: fieldUpdateWireBody(update, prepared) });
|
|
1035
1124
|
const readback = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } });
|
|
1036
1125
|
const after = wireRows(readback.data).find((row) => (row.code ?? row.propertyCode) === args.code);
|
|
@@ -1042,7 +1131,9 @@ async function runFieldUpdate(session, args) {
|
|
|
1042
1131
|
// failure never hides the field write (it is surfaced as formConditionSync.error).
|
|
1043
1132
|
let formConditionSync;
|
|
1044
1133
|
let formConditionSyncError;
|
|
1045
|
-
|
|
1134
|
+
const conditionKeys = new Set(Object.keys(FORM_CONDITION_OPTION_KEYS));
|
|
1135
|
+
const conditionChanged = effectiveOptions !== undefined && [...conditionKeys].some((key) => !fieldValuesEqual((decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {})[key], prepared.options[key]));
|
|
1136
|
+
if (conditionChanged && !prepared.isChildTable) {
|
|
1046
1137
|
const previous = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
|
|
1047
1138
|
try {
|
|
1048
1139
|
const sync = await syncFormConditionDataRules(session, args.schemaCode, args.code, previous, prepared.options);
|
|
@@ -1058,6 +1149,19 @@ async function runFieldUpdate(session, args) {
|
|
|
1058
1149
|
// confirm the write landed — the readback only named the field. Field
|
|
1059
1150
|
// options are the form-interaction contract (ADR 0005), so read them back.
|
|
1060
1151
|
const storedOptions = after !== undefined ? parseFieldOptionsForReadback(after.options) : undefined;
|
|
1152
|
+
if (args.options !== undefined && storedOptions && typeof storedOptions === "object" && !Array.isArray(storedOptions)) {
|
|
1153
|
+
const missed = Object.keys(args.options).filter((key) => !same(storedOptions[key] ?? null, prepared.options[key] ?? null));
|
|
1154
|
+
if (missed.length > 0) {
|
|
1155
|
+
throw new ToolRefusalError({
|
|
1156
|
+
status: "refused",
|
|
1157
|
+
blocker: "field-options-immutable",
|
|
1158
|
+
schemaCode: args.schemaCode,
|
|
1159
|
+
code: args.code,
|
|
1160
|
+
fields: missed.map((key) => `options.${key}`),
|
|
1161
|
+
message: `field.update 的 options.${missed.join("、")} 经此面提交后未生效;options 属于不可经此面修改的字段配置。请按字段族契约使用 field.delete + field.create 重建,或走对应的表单/数据规则工具(不要把 DELIVERED 当成已生效)。`,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1061
1165
|
// r32(真机复现:field.update propertyEmpty:true 回 ok/DELIVERED,field.list --full
|
|
1062
1166
|
// 与 model.inspect 都看不到该值):平台字段行读面(bizproperty/list 与
|
|
1063
1167
|
// bizproperty/get)都不回 propertyEmpty,这是**只写属性**。不编造读回值:
|
|
@@ -1083,8 +1187,12 @@ async function runFieldUpdate(session, args) {
|
|
|
1083
1187
|
face: "draft",
|
|
1084
1188
|
impact,
|
|
1085
1189
|
fieldPresent: after !== undefined,
|
|
1086
|
-
...(after?.name !== undefined
|
|
1087
|
-
|
|
1190
|
+
...(after?.name !== undefined || prepared.name !== undefined || record.name !== undefined || record.propertyName !== undefined
|
|
1191
|
+
? { name: after?.name ?? prepared.name ?? record.name ?? record.propertyName }
|
|
1192
|
+
: {}),
|
|
1193
|
+
...(typeof after?.name_i18n === "string" || typeof prepared.nameI18n === "string" || typeof record.name_i18n === "string"
|
|
1194
|
+
? { name_i18n: after?.name_i18n ?? prepared.nameI18n ?? record.name_i18n }
|
|
1195
|
+
: {}),
|
|
1088
1196
|
...(after?.type !== undefined || after?.propertyType !== undefined ? { type: after?.type ?? after?.propertyType } : {}),
|
|
1089
1197
|
...(hasReadBackPropertyEmpty ? { propertyEmpty: after?.propertyEmpty } : {}),
|
|
1090
1198
|
committed: committedKeys,
|
|
@@ -1349,6 +1457,13 @@ export async function executeFieldPublish(session, args) {
|
|
|
1349
1457
|
const preRead = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
|
|
1350
1458
|
const prePublishedRows = wireRows(preRead.data);
|
|
1351
1459
|
const prePublished = prePublishedRows.map(codeOf).filter((code) => code.length > 0);
|
|
1460
|
+
// Older servers accept a dangling relation row and only reveal it as the
|
|
1461
|
+
// misleading 300010 at model publish. Inspect the draft before POST so an
|
|
1462
|
+
// existing poisoned model gets the same field-specific diagnosis as create.
|
|
1463
|
+
const relationCandidates = await modelSchemaCodes(session);
|
|
1464
|
+
const relationFailure = relationPublishFailureMessage(wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } })).data), relationCandidates);
|
|
1465
|
+
if (relationFailure)
|
|
1466
|
+
throw new ToolRefusalError({ status: "refused", blocker: "relation-target-not-found", schemaCode: args.schemaCode, message: relationFailure, candidates: relationCandidates.slice(0, 5) });
|
|
1352
1467
|
// r29e F10/F11:旧短路只看「code 在发布面存在」,改了 options 仍报
|
|
1353
1468
|
// already-published + verification.ok(假绿)。再比草稿/发布 options 指纹;
|
|
1354
1469
|
// 不一致就真发布。name/type 仍由 verifyFieldDetails 核对。
|
package/dist/lanes/folder.js
CHANGED
|
@@ -5,7 +5,7 @@ import { writeTool } from "../write.js";
|
|
|
5
5
|
import { wireCall, wireRows } from "../wire.js";
|
|
6
6
|
import { ENDPOINTS } from "../endpoints.js";
|
|
7
7
|
import { capabilityArg, environmentArg } from "../args.js";
|
|
8
|
-
import { resolveApplicationCode } from "../support.js";
|
|
8
|
+
import { jsonBytes, resolveApplicationCode } from "../support.js";
|
|
9
9
|
import { LANES } from "./lane-ids.js";
|
|
10
10
|
import { FOLDER_LANE_CONTRACT } from "./folder-contract.js";
|
|
11
11
|
/**
|
|
@@ -38,6 +38,33 @@ import { FOLDER_LANE_CONTRACT } from "./folder-contract.js";
|
|
|
38
38
|
*/
|
|
39
39
|
const LANE = LANES.folder;
|
|
40
40
|
const T = FOLDER_LANE_CONTRACT.tools;
|
|
41
|
+
/**
|
|
42
|
+
* folder.tree is a node inventory, so its useful inline budget is node-count
|
|
43
|
+
* first rather than the generic read compact threshold. Keep the existing
|
|
44
|
+
* byte guard as a second gate: a pathological 50-node tree still belongs in
|
|
45
|
+
* the artifact when the node payload itself is too large.
|
|
46
|
+
*/
|
|
47
|
+
export const FOLDER_TREE_INLINE_NODE_LIMIT = 50;
|
|
48
|
+
export const FOLDER_TREE_NODE_INLINE_CEILING_BYTES = 8192;
|
|
49
|
+
export function compactFolderTree(result) {
|
|
50
|
+
const nodes = Array.isArray(result.nodes) ? result.nodes : undefined;
|
|
51
|
+
if (nodes === undefined)
|
|
52
|
+
return { ...result };
|
|
53
|
+
if (nodes.length <= FOLDER_TREE_INLINE_NODE_LIMIT && jsonBytes(nodes) <= FOLDER_TREE_NODE_INLINE_CEILING_BYTES) {
|
|
54
|
+
return { ...result, nodes };
|
|
55
|
+
}
|
|
56
|
+
const countGate = nodes.length > FOLDER_TREE_INLINE_NODE_LIMIT;
|
|
57
|
+
const { nodes: _nodes, ...withoutNodes } = result;
|
|
58
|
+
return {
|
|
59
|
+
...withoutNodes,
|
|
60
|
+
nodesPreview: nodes.slice(0, 5),
|
|
61
|
+
note: `${String(result.note ?? "")} ${countGate
|
|
62
|
+
? `nodes 超过 ${FOLDER_TREE_INLINE_NODE_LIMIT} 条,完整节点已外置到 detailArtifactId;nodesPreview 仅展示前 5 条。`
|
|
63
|
+
: `nodes 正文超过 ${FOLDER_TREE_NODE_INLINE_CEILING_BYTES}B 字节闸,完整节点已外置到 detailArtifactId;这是字节闸,不是节点数限制。`}`.trim(),
|
|
64
|
+
// Deliberately omit nodes so governedRead adds the standard empty
|
|
65
|
+
// placeholder/truncatedKeys while the preview remains available.
|
|
66
|
+
};
|
|
67
|
+
}
|
|
41
68
|
/**
|
|
42
69
|
* Live 1.1.25 (r28d F2): platform `apppackage/create` seeds one top-level Folder
|
|
43
70
|
* whose `name_i18n.en` is the stable token `defaultGroup` (zh display is「默认分组」).
|
|
@@ -202,7 +229,7 @@ function folderTools(tools) {
|
|
|
202
229
|
: " applicationExists:false——应用不在 apppackage/list;空树不等于「空应用」。") +
|
|
203
230
|
" nodeCount/folderCount/modelCount 是全应用聚合,共享环境含其它会话资源;核对自己的资源请按 code 前缀过滤 nodes[],不要只比计数。",
|
|
204
231
|
};
|
|
205
|
-
}),
|
|
232
|
+
}, { compact: compactFolderTree }),
|
|
206
233
|
}));
|
|
207
234
|
writeTool({
|
|
208
235
|
tools,
|
|
@@ -116,7 +116,7 @@ export const MODEL_LANE_CONTRACT = Object.freeze({
|
|
|
116
116
|
}),
|
|
117
117
|
configure_model: Object.freeze({
|
|
118
118
|
scope: MODEL_SCOPE,
|
|
119
|
-
description: "High-level: create a model with one field (fieldCode/fieldName/propertyType, legacy) or N fields (fields:[{code,name,propertyType,choices?,relativeCode?,options?}]) and drive it to the structurally usable state (model.create → field.create×N → model-scoped field.publish), optionally drafting and publishing the default form in the same package (publishForm:true → form.draft → form.publish) and configuring the default list view (`listview:{fieldCodes,...}` → listview.configure). Resumable: if the model already exists
|
|
119
|
+
description: "High-level: create a model with one field (fieldCode/fieldName/propertyType, legacy) or N fields (fields:[{code,name,propertyType,choices?,relativeCode?,options?}]) and drive it to the structurally usable state (model.create → field.create×N → model-scoped field.publish), optionally drafting and publishing the default form in the same package (publishForm:true → form.draft → form.publish) and configuring the default list view (`listview:{fieldCodes,...}` → listview.configure). Resumable: if the model already exists, the readback declares `created:false` and `mergedChanges`; `createOnly:true` refuses with `model-already-exists`. Model code is tenant-global and cannot be reused across applications. Family gates run per field BEFORE the write permission gate; for the form-only path use configure_form.",
|
|
120
120
|
}),
|
|
121
121
|
"model.deleteMany": Object.freeze({
|
|
122
122
|
scope: MODEL_SCOPE,
|
package/dist/lanes/model.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ENDPOINTS } from "../endpoints.js";
|
|
3
|
-
import { assertFieldFamilyAllowed, assertSelectionChoicesAllowed, choicesArg, fieldOptionsArg, nameI18nJson, propertyTypeArg, relationTargetOf, relativeCodeArg, SYSTEM_FIELD_CODES, subFieldArg, } from "../field-families.js";
|
|
3
|
+
import { assertFieldFamilyAllowed, assertSelectionChoicesAllowed, choicesArg, fieldOptionsArg, nameI18nJson, propertyTypeArg, relationTargetOf, relativeCodeArg, SYSTEM_FIELD_CODES, subFieldArg, assertRelationTargetExists, } from "../field-families.js";
|
|
4
|
+
import { modelSchemaCodes } from "./field.js";
|
|
4
5
|
import { fieldChangeWireBody, prepareFieldChange, sameFieldChange } from "../field-change.js";
|
|
5
6
|
import { executeFormDraftWrite, executeFormPublishWrite } from "../form-support.js";
|
|
6
7
|
import { mapBounded } from "../support.js";
|
|
@@ -9,7 +10,8 @@ import { writeSummary } from "../human-summary.js";
|
|
|
9
10
|
import { capabilityArg, environmentArg } from "../args.js";
|
|
10
11
|
import { governedRead, withoutReadbackCache } from "../read.js";
|
|
11
12
|
import { parseToolInput } from "../tool-types.js";
|
|
12
|
-
import { applicationResolutionOf, applicationScopeHint, applicationScopeNotice, flattenTreeRows, resolveApplicationCode
|
|
13
|
+
import { applicationResolutionOf, applicationScopeHint, applicationScopeNotice, flattenTreeRows, resolveApplicationCode } from "../support.js";
|
|
14
|
+
import { resolveValidatedApplicationScope } from "./app.js";
|
|
13
15
|
import { writeTool } from "../write.js";
|
|
14
16
|
import { registerPackageTool } from "../package-tool.js";
|
|
15
17
|
import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
|
|
@@ -251,28 +253,11 @@ export function registerModelLane(tools) {
|
|
|
251
253
|
capability: capabilityArg,
|
|
252
254
|
},
|
|
253
255
|
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
254
|
-
const scope = await
|
|
256
|
+
const scope = await resolveValidatedApplicationScope(session, args.applicationCode);
|
|
255
257
|
const applicationCode = scope.code;
|
|
256
258
|
// r29f F6:显式/配置的 applicationCode 若不在 app.list 里,旧路径会把拼写错误
|
|
257
259
|
// 伪装成「该应用没有模型」。先核对租户应用表,未命中即拒(与 discover 的
|
|
258
260
|
// applicationResolution.status 同构)。
|
|
259
|
-
const availableApps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data)
|
|
260
|
-
.map((row) => String(row.appCode ?? row.code ?? ""))
|
|
261
|
-
.filter((code) => code !== "");
|
|
262
|
-
if (availableApps.length > 0 && !availableApps.includes(applicationCode)) {
|
|
263
|
-
throw new ToolRefusalError({
|
|
264
|
-
status: "refused",
|
|
265
|
-
blocker: "app-not-found",
|
|
266
|
-
applicationCode,
|
|
267
|
-
applicationResolution: {
|
|
268
|
-
status: "unresolved",
|
|
269
|
-
source: scope.source,
|
|
270
|
-
requested: scope.requested ?? applicationCode,
|
|
271
|
-
available: availableApps.slice(0, 40),
|
|
272
|
-
},
|
|
273
|
-
message: `应用「${applicationCode}」不在租户 app.list 里(不是「该应用没有模型」)。可用:${availableApps.slice(0, 12).join("、")}${availableApps.length > 12 ? " 等" : ""};先 app.list 取正确 applicationCode。`,
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
261
|
const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
|
|
277
262
|
const all = tree
|
|
278
263
|
.filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code)
|
|
@@ -361,14 +346,15 @@ export function registerModelLane(tools) {
|
|
|
361
346
|
// 节点本来就带 name,与六面同一批并发读,零额外顺序往返。
|
|
362
347
|
const treeFace = async () => {
|
|
363
348
|
try {
|
|
364
|
-
|
|
349
|
+
const resolved = await resolveModelTreeNode(session, schemaCode);
|
|
350
|
+
return { node: resolved.node, applicationCode: resolved.applicationCode };
|
|
365
351
|
}
|
|
366
352
|
catch {
|
|
367
353
|
return undefined;
|
|
368
354
|
}
|
|
369
355
|
};
|
|
370
356
|
const [fieldFace, formFace, listviewFace, ruleFace, dataruleFace, draftFace, treeRead] = await mapBounded([
|
|
371
|
-
() => face("field.list", { schemaCode, published: true, ...(all ? { all: true } : {}) }, faceErrcodes),
|
|
357
|
+
() => face("field.list", { schemaCode, published: true, ...(all ? { all: true, full: true } : {}) }, faceErrcodes),
|
|
372
358
|
() => face("form.get", { schemaCode }),
|
|
373
359
|
() => face("listview.get", { schemaCode }),
|
|
374
360
|
() => face("rule.list", { schemaCode }),
|
|
@@ -378,10 +364,21 @@ export function registerModelLane(tools) {
|
|
|
378
364
|
], 7, (task) => task());
|
|
379
365
|
const treeNode = treeRead?.node;
|
|
380
366
|
const modelName = typeof treeNode?.name === "string" && treeNode.name ? treeNode.name : typeof treeNode?.text === "string" && treeNode.text ? treeNode.text : undefined;
|
|
381
|
-
|
|
367
|
+
let fieldRows = Array.isArray(fieldFace?.fields) ? fieldFace.fields : [];
|
|
368
|
+
if (all && typeof fieldFace?.detailFile === "string" && !fieldRows.some((field) => field.options !== undefined)) {
|
|
369
|
+
try {
|
|
370
|
+
const { readFileSync } = await import("node:fs");
|
|
371
|
+
const artifact = JSON.parse(readFileSync(fieldFace.detailFile, "utf8"));
|
|
372
|
+
if (Array.isArray(artifact.fields))
|
|
373
|
+
fieldRows = artifact.fields;
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// Keep the compact field inventory if the nested artifact is unavailable.
|
|
377
|
+
}
|
|
378
|
+
}
|
|
382
379
|
const fields = fieldRows
|
|
383
380
|
.filter((field) => all || !SYSTEM_FIELD_CODES.has(String(field.code)))
|
|
384
|
-
.map((field) => ({ code: field.code, type: field.type, ...(field.typeName !== undefined ? { typeName: field.typeName } : {}), name: field.name, ...(field.conditions ? { conditions: field.conditions } : {}) }));
|
|
381
|
+
.map((field) => ({ code: field.code, type: field.type, ...(field.typeName !== undefined ? { typeName: field.typeName } : {}), name: field.name, ...(field.conditions ? { conditions: field.conditions } : {}), ...(all && field.options !== undefined ? { options: field.options } : {}) }));
|
|
385
382
|
const form = formFace && formFace.present !== undefined
|
|
386
383
|
? { present: formFace.present === true, published: formFace.published === true, controlCount: Array.isArray(formFace.draftControlCodes) ? formFace.draftControlCodes.length : 0 }
|
|
387
384
|
: undefined;
|
|
@@ -454,6 +451,7 @@ export function registerModelLane(tools) {
|
|
|
454
451
|
return {
|
|
455
452
|
code: schemaCode,
|
|
456
453
|
schemaCode,
|
|
454
|
+
...(typeof treeRead?.applicationCode === "string" && treeRead.applicationCode ? { applicationCode: treeRead.applicationCode } : {}),
|
|
457
455
|
...(modelName !== undefined ? { name: modelName } : {}),
|
|
458
456
|
...(modelPresent !== undefined ? { modelPresent } : {}),
|
|
459
457
|
...(modelPresent === false
|
|
@@ -488,10 +486,14 @@ export function registerModelLane(tools) {
|
|
|
488
486
|
return result;
|
|
489
487
|
return {
|
|
490
488
|
schemaCode: result.schemaCode,
|
|
489
|
+
...(result.applicationCode !== undefined ? { applicationCode: result.applicationCode } : {}),
|
|
491
490
|
// r35a-F3:compact 投影必须保留 code(model.list 有 code,inspect 此前
|
|
492
491
|
// 只剩 schemaCode,`--jq '[].code'` 必 pathMiss 白跑一轮)。
|
|
493
492
|
...(result.code !== undefined ? { code: result.code } : {}),
|
|
494
493
|
fieldCodes: (result.fields ?? []).map((field) => field.code),
|
|
494
|
+
...(Array.isArray(result.fields) && result.fields.some((field) => field.options !== undefined)
|
|
495
|
+
? { fields: result.fields }
|
|
496
|
+
: {}),
|
|
495
497
|
form: result.form,
|
|
496
498
|
listview: result.listview ? { code: result.listview.code, columnCount: result.listview.columnCount } : undefined,
|
|
497
499
|
ruleCodes: (result.rules ?? []).map((rule) => rule.code),
|
|
@@ -533,15 +535,17 @@ export function registerModelLane(tools) {
|
|
|
533
535
|
// r29a F6:dry-run 必须做与实跑同样的 duplicate 探测,否则会假绿灯。
|
|
534
536
|
dryRunPayload: modelCreateDryRun,
|
|
535
537
|
execute: async (session, args) => {
|
|
536
|
-
const
|
|
538
|
+
const applicationScope = await resolveValidatedApplicationScope(session, args.applicationCode);
|
|
539
|
+
const applicationCode = applicationScope.code;
|
|
537
540
|
const existing = await resolveModelTreeNode(session, args.code, args.applicationCode);
|
|
538
541
|
if (existing.node) {
|
|
539
542
|
throw new ToolRefusalError({
|
|
540
543
|
status: "refused",
|
|
541
544
|
blocker: "model-create-duplicate",
|
|
542
545
|
schemaCode: args.code,
|
|
543
|
-
|
|
544
|
-
|
|
546
|
+
requestedApplicationCode: applicationCode,
|
|
547
|
+
conflictInApplicationCode: existing.applicationCode,
|
|
548
|
+
message: `模型 code 在租户内全局唯一,跨应用同名即冲突:模型 ${args.code} 已存在于应用 ${existing.applicationCode};create 拒绝重复 POST`,
|
|
545
549
|
});
|
|
546
550
|
}
|
|
547
551
|
// A tree-miss does NOT prove absence: an orphan mount (row exists in
|
|
@@ -1305,6 +1309,7 @@ export function registerModelLane(tools) {
|
|
|
1305
1309
|
.optional()
|
|
1306
1310
|
.describe("也可在本调用内配置默认列表视图(内部走 listview.configure);一次到「模型+字段+表单+列表」"),
|
|
1307
1311
|
applicationCode: z.string().optional(),
|
|
1312
|
+
createOnly: z.boolean().optional().describe("true=模型已存在时拒绝,不执行字段合并"),
|
|
1308
1313
|
},
|
|
1309
1314
|
// CFG-naming/CFG-02 parity: refuse an invalid code before a plan
|
|
1310
1315
|
// is minted (the executor's model.create/field.create would otherwise
|
|
@@ -1358,6 +1363,12 @@ export function registerModelLane(tools) {
|
|
|
1358
1363
|
execute: async (session, args) => {
|
|
1359
1364
|
const results = [];
|
|
1360
1365
|
const entries = fieldEntriesOf(args);
|
|
1366
|
+
// r37 跨路接线(同扇门):field.create 单独调用已对关联字段目标写前拒
|
|
1367
|
+
// (relation-target-not-found),configure_model 的字段包不能是例外——
|
|
1368
|
+
// 否则混合包仍会落到裸 300010 把整个模型毒住。
|
|
1369
|
+
const relationCandidates = await modelSchemaCodes(session);
|
|
1370
|
+
for (const entry of entries)
|
|
1371
|
+
assertRelationTargetExists(entry, relationCandidates);
|
|
1361
1372
|
const stop = (index, tool, error) => {
|
|
1362
1373
|
const envelope = error instanceof ToolRefusalError
|
|
1363
1374
|
? Object.fromEntries(Object.entries(error.payload).filter(([key]) => key !== "status"))
|
|
@@ -1380,7 +1391,8 @@ export function registerModelLane(tools) {
|
|
|
1380
1391
|
return stop(index, tool, error);
|
|
1381
1392
|
}
|
|
1382
1393
|
};
|
|
1383
|
-
const
|
|
1394
|
+
const applicationScope = await resolveValidatedApplicationScope(session, args.applicationCode);
|
|
1395
|
+
const applicationCode = applicationScope.code;
|
|
1384
1396
|
const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
|
|
1385
1397
|
const folder = args.folderId ? tree.find((row) => row.id === args.folderId) : tree.find((row) => row.type !== "BizModel" && typeof row.id === "string");
|
|
1386
1398
|
if (!folder?.id)
|
|
@@ -1391,6 +1403,10 @@ export function registerModelLane(tools) {
|
|
|
1391
1403
|
// or form step. The application tree is already in hand, so an existing
|
|
1392
1404
|
// model is detected with no extra call: skip the create and continue.
|
|
1393
1405
|
const existingModel = tree.find((row) => row.type === "BizModel" && row.code === args.code);
|
|
1406
|
+
if (existingModel && args.createOnly === true) {
|
|
1407
|
+
throw new ToolRefusalError({ status: "refused", blocker: "model-already-exists", requestedApplicationCode: applicationCode, conflictInApplicationCode: applicationCode, schemaCode: args.code, message: `模型 code 在租户内全局唯一,跨应用同名即冲突:${args.code} 已存在;createOnly=true 拒绝合并` });
|
|
1408
|
+
}
|
|
1409
|
+
const created = !existingModel;
|
|
1394
1410
|
let failed;
|
|
1395
1411
|
if (existingModel) {
|
|
1396
1412
|
results.push({ step: "model.create", skipped: "model-exists", folderId: String(folder.id), readback: { present: true } });
|
|
@@ -1407,6 +1423,7 @@ export function registerModelLane(tools) {
|
|
|
1407
1423
|
return failed;
|
|
1408
1424
|
}
|
|
1409
1425
|
const existingFields = new Map();
|
|
1426
|
+
const mergedFieldCodes = [];
|
|
1410
1427
|
for (const isPublish of ["false", "true"]) {
|
|
1411
1428
|
for (const row of wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.code, isPublish } })).data))
|
|
1412
1429
|
existingFields.set(String(row.code ?? row.propertyCode ?? ""), row);
|
|
@@ -1417,8 +1434,10 @@ export function registerModelLane(tools) {
|
|
|
1417
1434
|
failed = await run(results.length, "field.create", async () => {
|
|
1418
1435
|
const existing = existingFields.get(entry.code);
|
|
1419
1436
|
if (existing !== undefined) {
|
|
1420
|
-
if (sameFieldChange(existing, input))
|
|
1437
|
+
if (sameFieldChange(existing, input)) {
|
|
1438
|
+
mergedFieldCodes.push(entry.code);
|
|
1421
1439
|
return { step: "field.create", skipped: "field-exists", code: entry.code, readback: { present: true } };
|
|
1440
|
+
}
|
|
1422
1441
|
throw new ToolRefusalError({
|
|
1423
1442
|
status: "refused",
|
|
1424
1443
|
blocker: "field-conflict",
|
|
@@ -1432,6 +1451,7 @@ export function registerModelLane(tools) {
|
|
|
1432
1451
|
const after = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.code, isPublish: "false" } })).data);
|
|
1433
1452
|
if (!after.some((row) => String(row.code ?? row.propertyCode) === entry.code))
|
|
1434
1453
|
throw new ToolRefusalError({ status: "refused", blocker: "field-create-readback-failed", message: `field.create 后权威草稿字段未读到 ${entry.code}` });
|
|
1454
|
+
mergedFieldCodes.push(entry.code);
|
|
1435
1455
|
return { step: "field.create", errcode: performed.errcode, code: entry.code, readback: { present: true } };
|
|
1436
1456
|
});
|
|
1437
1457
|
if (failed)
|
|
@@ -1484,6 +1504,8 @@ export function registerModelLane(tools) {
|
|
|
1484
1504
|
const rows = wireRows(published.data);
|
|
1485
1505
|
const publishedCodes = rows.map((row) => String(row.code ?? row.propertyCode ?? ""));
|
|
1486
1506
|
const readback = {
|
|
1507
|
+
created,
|
|
1508
|
+
...(created ? {} : { mergedChanges: { fields: mergedFieldCodes, nameIgnored: args.name !== String(existingModel?.name ?? args.name) } }),
|
|
1487
1509
|
publishedCodes,
|
|
1488
1510
|
...(entries.length === 1
|
|
1489
1511
|
? { publishedFieldPresent: publishedCodes.includes(entries[0].code) }
|
|
@@ -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: `**本调用 = 改 JS +
|
|
81
|
+
description: `**本调用 = 改 JS + 立即发布整张表单**(force:true 重推,不需要再 form.publish——它只提拔布局、不碰 JS)。publish:false 只写草稿;remove:true 或 source:"" 移除脚本。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
|
@@ -77,7 +77,7 @@ export function registerOnlineJsLane(tools) {
|
|
|
77
77
|
return {
|
|
78
78
|
schemaCode: args.schemaCode,
|
|
79
79
|
sheetCode,
|
|
80
|
-
present:
|
|
80
|
+
present: draft.present || published.present,
|
|
81
81
|
sheetPublished: sheet.published === true,
|
|
82
82
|
draft: { ...draft, ...(draft.script === null ? { note: "草稿无 customScript 脚本(尚未 compose 或被清空)" } : {}) },
|
|
83
83
|
published: { ...published, ...(published.script === null ? { note: "发布面无 customScript 脚本" } : {}) },
|
|
@@ -107,13 +107,19 @@ export function registerOnlineJsLane(tools) {
|
|
|
107
107
|
// 的 sheetCode 都是 optional(省略 = schemaCode)。此前只有这里必填,
|
|
108
108
|
// dry-run 与真写都拒——同一个 lane 两套约定,调用方要白撞一轮才知道。
|
|
109
109
|
sheetCode: z.string().min(1).optional().describe("表单槽位码;省略时默认 = schemaCode(与 form.draft/form.publish 同口径)"),
|
|
110
|
-
source: z.string().
|
|
110
|
+
source: z.string().optional().describe("在线 JS 代码。默认 INCREMENTAL:并入平台 (function(form){…}) 模板的既有脚本,模板与既有注册全部保留;配 replace:true 才是整段替换。脚本不得使用 export/import;事件名必须是平台事件(onLoad/onRendered/onValidate/onPreAction/onCustomAction/onActionDone/onPrintFormDataHandle/onPrintWorkflowLogDataHandle)。与 remove:true 二选一;source 为空字符串也表示移除 customScript"),
|
|
111
111
|
replace: z.boolean().optional().describe("true = 以 source 整体替换 customScript(含完整 (function(form){…}) 脚本)。默认 false = 增量并入既有脚本,保留平台模板与既有逻辑"),
|
|
112
|
-
|
|
112
|
+
remove: z.boolean().optional().describe("true = 显式移除 customScript,使 onlinejs.get 返回 present:false;会写入并按 publish 设置发布。与 source 二选一"),
|
|
113
|
+
publish: z.boolean().optional().describe("默认 true:写草稿并推发布面;false = 只写草稿不推发布面"),
|
|
113
114
|
summary: z.boolean().optional().describe("true = 只回同步状态(sha/字节数/是否同步/渲染兼容),不回任何 HTML/JS 原文。确认「改动是否生效」用这个;要看原文用 onlinejs.get"),
|
|
114
115
|
},
|
|
115
116
|
execute: async (session, args) => {
|
|
116
117
|
const shouldPublish = args.publish !== false;
|
|
118
|
+
const removing = args.remove === true || args.source === "";
|
|
119
|
+
if (!removing && args.source === undefined)
|
|
120
|
+
throw new Error("必须提供 source,或显式传 remove:true");
|
|
121
|
+
if (args.remove === true && args.source !== undefined && args.source !== "")
|
|
122
|
+
throw new Error("remove:true 不能与非空 source 同时传入");
|
|
117
123
|
// r28e F9:和同 lane 其它工具一致——省略 sheetCode 即模型码槽位。
|
|
118
124
|
const targetSheetCode = args.sheetCode ?? args.schemaCode;
|
|
119
125
|
// 1. Read the existing draftHtmlJson.
|
|
@@ -133,10 +139,13 @@ export function registerOnlineJsLane(tools) {
|
|
|
133
139
|
// existing script and merge the caller's code into it; the platform
|
|
134
140
|
// default (an empty script) is seeded with the lifecycle scaffold.
|
|
135
141
|
const existingScript = String(existingElements.find((element) => element.id === "customScript")?.innerHTML ?? "");
|
|
136
|
-
const composedScript = composeCustomScript(existingScript, args.source, args.replace === true ? "replace" : "merge");
|
|
142
|
+
const composedScript = removing ? "" : composeCustomScript(existingScript, args.source, args.replace === true ? "replace" : "merge");
|
|
137
143
|
// 3. Preserve the platform template read-modify-write: keep the
|
|
138
144
|
// style/beforeCustomScript/afterCustomScript skeleton and every
|
|
139
145
|
// layout/control element; customScript carries the MERGED script.
|
|
146
|
+
// Keep the platform's required six-element skeleton, but an empty
|
|
147
|
+
// customScript element is semantically absent (onlinejs.get reports
|
|
148
|
+
// present:false) and remains accepted by the renderer contract.
|
|
140
149
|
const composedElements = composeLayoutElements(existingElements, composedScript);
|
|
141
150
|
assertHtmlRender(JSON.stringify(composedElements), { schemaCode: args.schemaCode, sheetCode: targetSheetCode, face: "composedDraftHtmlJson" }, true);
|
|
142
151
|
// 4. Stringify the COMPLETE element array (never partial).
|
|
@@ -176,7 +185,10 @@ export function registerOnlineJsLane(tools) {
|
|
|
176
185
|
const publishedScript = readCustomScriptSource(publishedRaw).script;
|
|
177
186
|
const customScriptSha256 = createHash("sha256").update(composedScript, "utf8").digest("hex");
|
|
178
187
|
const draftPublishedInSync = publishedScript !== null && publishedScript === composedScript;
|
|
179
|
-
const htmlRenderCompat = {
|
|
188
|
+
const htmlRenderCompat = {
|
|
189
|
+
draft: { ...validateHtmlRender(composedDraftHtml, true), note: "仅 HTML 结构不含 JS 运行" },
|
|
190
|
+
published: { ...validateHtmlRender(publishedRaw, true), note: "仅 HTML 结构不含 JS 运行" },
|
|
191
|
+
};
|
|
180
192
|
// Live 2026-10-21 (customer): this readback shipped the same ~10KB script
|
|
181
193
|
// three times (composedDraftHtmlJson + publishedHtmlJson + customScript),
|
|
182
194
|
// ~45KB for a call whose only question was "did publish land?" — the
|
|
@@ -222,13 +234,18 @@ export function registerOnlineJsLane(tools) {
|
|
|
222
234
|
customScriptMatchesPublished: draftPublishedInSync,
|
|
223
235
|
customScriptMerged: args.replace !== true,
|
|
224
236
|
htmlRenderCompat,
|
|
237
|
+
portalVerificationPlan: ["在门户打开该表单", "触发已绑定的在线 JS 事件", "检查事件产生的副作用字段或校验提示"],
|
|
225
238
|
},
|
|
226
239
|
};
|
|
227
240
|
},
|
|
228
241
|
preflight: // Refuse a source that cannot run in the script slot (export/import, unknown
|
|
229
242
|
// form.on event) BEFORE the write permission gate. This is the exact
|
|
230
243
|
// class of mistake that blanked a live form page with no harness signal.
|
|
231
|
-
(args) =>
|
|
244
|
+
(args) => {
|
|
245
|
+
if (args.remove === true || args.source === "" || args.source === undefined)
|
|
246
|
+
return;
|
|
247
|
+
lintOnlineJsSource(args.source);
|
|
248
|
+
},
|
|
232
249
|
dryRunPayload: // r23 F4:dry-run 必须走与真写相同的 compose 校验路径(含 replace/merge 的
|
|
233
250
|
// wrapper 判定),否则 dry-run 过、真写拒——探针按 dry-run 绿灯写正式调用被拒。
|
|
234
251
|
async (session, args) => {
|
|
@@ -238,12 +255,27 @@ export function registerOnlineJsLane(tools) {
|
|
|
238
255
|
const compat = validateHtmlRender(raw);
|
|
239
256
|
const elements = compat.ok ? parseLayoutElements(raw) : [];
|
|
240
257
|
const existingScript = String(elements.find((element) => element.id === "customScript")?.innerHTML ?? "");
|
|
241
|
-
const
|
|
258
|
+
const removing = args.remove === true || args.source === "";
|
|
259
|
+
if (!removing && args.source === undefined)
|
|
260
|
+
throw new Error("必须提供 source,或显式传 remove:true");
|
|
261
|
+
const composed = removing ? "" : composeCustomScript(existingScript, args.source, args.replace === true ? "replace" : "merge");
|
|
262
|
+
const composedBytes = Buffer.byteLength(composed, "utf8");
|
|
263
|
+
const previewSize = 200;
|
|
264
|
+
const composedPreview = composed.length <= previewSize * 2
|
|
265
|
+
? composed
|
|
266
|
+
: `${composed.slice(0, previewSize)}…${composed.slice(-previewSize)}`;
|
|
242
267
|
return {
|
|
243
268
|
mode: args.replace === true ? "replace" : "merge",
|
|
269
|
+
publish: args.publish !== false,
|
|
244
270
|
wouldChange: composed !== existingScript,
|
|
245
271
|
composedSha256: createHash("sha256").update(composed, "utf8").digest("hex"),
|
|
246
|
-
composedBytes
|
|
272
|
+
composedBytes,
|
|
273
|
+
composedPreview,
|
|
274
|
+
composedPreviewHeadBytes: Buffer.byteLength(composed.slice(0, previewSize), "utf8"),
|
|
275
|
+
composedPreviewTailBytes: Buffer.byteLength(composed.slice(-previewSize), "utf8"),
|
|
276
|
+
composedPreviewTotalBytes: composedBytes,
|
|
277
|
+
composedPreviewNote: "预览为头/尾各 200 个字符(短文本不截断);字节数以 UTF-8 提交串对账,未自动补换行或外壳",
|
|
278
|
+
summary: args.publish === false ? "只写草稿,不推发布面" : "写草稿并推发布面",
|
|
247
279
|
hint: "dry-run 已执行与真写一致的 compose 校验;该结果即正式调用将落库的脚本形状",
|
|
248
280
|
};
|
|
249
281
|
},
|
|
@@ -267,7 +299,7 @@ export function registerOnlineJsLane(tools) {
|
|
|
267
299
|
inverse: ({ before, args }) => {
|
|
268
300
|
const source = before?.customScript;
|
|
269
301
|
if (typeof source !== "string")
|
|
270
|
-
return { kind: "
|
|
302
|
+
return { kind: "calls", calls: [{ tool: "onlinejs.compose", args: { schemaCode: String(args.schemaCode), sheetCode: String(args.sheetCode ?? args.schemaCode), remove: true, publish: args.publish } }] };
|
|
271
303
|
return { kind: "calls", calls: [{ tool: "onlinejs.compose", args: { schemaCode: String(args.schemaCode), sheetCode: String(args.sheetCode ?? args.schemaCode), source, replace: true, publish: args.publish } }] };
|
|
272
304
|
},
|
|
273
305
|
},
|