@stalfh233/omc-cli 0.4.74 → 0.4.76

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.
@@ -4,7 +4,7 @@ import { decodeJsonObjectColumn } from "./json-column.js";
4
4
  import { ToolRefusalError } from "./tool-types.js";
5
5
  import { FIELD_CONTROL_TYPES, SYSTEM_FIELD_CODES, fieldControlOptions, relationDisplayField, relationMappings, } from "./field-families.js";
6
6
  import { deriveControlOptions } from "./capability-shape.js";
7
- import { validateFormLayoutShape } from "./form-layout.js";
7
+ import { hardLayoutViolations, validateFormLayoutShape } from "./form-layout.js";
8
8
  /**
9
9
  * Propagate each relation field's `options.mappings` into its form control so
10
10
  * the form-fill carry is configured where the form renderer reads it. The
@@ -602,9 +602,7 @@ export function isEmptyLayoutJson(value) {
602
602
  * face (one-sided included), covering configure_form/apply_changes paths that
603
603
  * do not pass through the `form.draft` write permission gate.
604
604
  */
605
- export function assertCallerFormLayout(args) {
606
- if (args.draftViewJson === undefined && args.draftAttributesJson === undefined)
607
- return;
605
+ export function formLayoutViolations(args) {
608
606
  const parse = (value) => {
609
607
  if (typeof value !== "string")
610
608
  return undefined;
@@ -615,7 +613,12 @@ export function assertCallerFormLayout(args) {
615
613
  return undefined;
616
614
  }
617
615
  };
618
- const violations = validateFormLayoutShape(parse(args.draftViewJson), parse(args.draftAttributesJson));
616
+ return validateFormLayoutShape(parse(args.draftViewJson), parse(args.draftAttributesJson));
617
+ }
618
+ export function assertCallerFormLayout(args) {
619
+ if (args.draftViewJson === undefined && args.draftAttributesJson === undefined)
620
+ return;
621
+ const violations = hardLayoutViolations(formLayoutViolations(args));
619
622
  if (violations.length > 0) {
620
623
  throw new ToolRefusalError({
621
624
  status: "refused",
@@ -665,14 +668,177 @@ export function refuseEmptyCallerLayout(args) {
665
668
  message: `显式提供的 ${emptyFaces.join("、")} 是空布局("[]"/"{}")——平台设计器 Save 会把草稿清成该形态(非无损),照写会丢控件。若确要清空布局请显式传 allowEmptyLayout:true;若是设计器 Save 后的修复,先用表单版本历史(/api/api/app/bizsheet/history/list)取回完好版本原文再写回`,
666
669
  });
667
670
  }
671
+ /**
672
+ * Form-toolbar buttons are a THREE-LAYER configuration (live 2026-10-23, 1.1.25
673
+ * + backend source):
674
+ * 1. `bizsheet.options.customButtonJson` (a STRINGIFIED JSON array) is the
675
+ * authoring face — `ModelManagementFacadeImpl.publishBizForm:1022` turns it
676
+ * into `h_biz_button` rows with `triggerType=FORM`, `triggerCode=<formCode>`
677
+ * at publish time.
678
+ * 2. Those rows are what `ActionPermissionHandler` reads
679
+ * (`getByTriggerTypeAndCode(FORM, bizCode)`).
680
+ * 3. Whether a button shows at a given node is filtered by the workflow
681
+ * activity's `buttonPermission.customBtns[].enabled` (the designer's
682
+ * 「表单操作」 checkboxes) plus `showPermCode`.
683
+ *
684
+ * `customButtonJson` MUST stay a string inside the options JSON — passing the
685
+ * array inline makes the platform answer 50000 (fastjson
686
+ * "syntax error, position at 0, name customButtonJson").
687
+ */
688
+ export const FORM_BUTTON_LOCATIONS = Object.freeze(["FORM", "ROW", "LIST"]);
689
+ const FORM_BUTTON_BIND_ACTIONS = new Set([
690
+ "LIST",
691
+ "FORM",
692
+ "ADD_DATA",
693
+ "FLOW",
694
+ "BUSINESS_RULE",
695
+ "SCRIPT",
696
+ "CUSTOM_PAGE",
697
+ "WORKFLOW_CENTER_PAGE",
698
+ "FRONT_EVENT",
699
+ "AGENT",
700
+ ]);
701
+ const FORM_BUTTON_OPERATE_TYPES = new Set(["WITH_VIEW", "WITH_OUT_VIEW"]);
702
+ /** Validate the caller's form-button list before any wire (same shape as listview buttons, FORM location). */
703
+ export function validateFormButtons(buttons, tool) {
704
+ if (!Array.isArray(buttons))
705
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons 必须是数组(BizButtonModel 数组)` });
706
+ const seen = new Set();
707
+ return buttons.map((raw, index) => {
708
+ const button = (raw ?? {});
709
+ const code = typeof button.code === "string" ? button.code : "";
710
+ const name = typeof button.name === "string" ? button.name : "";
711
+ if (code === "")
712
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].code 必填(表单按钮的唯一标识,流程节点按它勾选)` });
713
+ if (name === "")
714
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].name 必填(设计器/表单页显示名)` });
715
+ if (seen.has(code))
716
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-duplicate", message: `${tool}: buttons 里 code ${code} 重复` });
717
+ seen.add(code);
718
+ const useLocation = typeof button.useLocation === "string" ? button.useLocation : "FORM";
719
+ if (!FORM_BUTTON_LOCATIONS.includes(useLocation))
720
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].useLocation 只能是 ${FORM_BUTTON_LOCATIONS.join("|")}(表单工具栏按钮用 FORM)` });
721
+ const bindAction = typeof button.bindAction === "string" ? button.bindAction : "";
722
+ if (!FORM_BUTTON_BIND_ACTIONS.has(bindAction))
723
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].bindAction 必须是 ButtonBindAction 名(SCRIPT/BUSINESS_RULE/FLOW/FORM/ADD_DATA/CUSTOM_PAGE/…)` });
724
+ const operateType = typeof button.operateType === "string" ? button.operateType : "";
725
+ if (!FORM_BUTTON_OPERATE_TYPES.has(operateType))
726
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}].operateType 必须是 WITH_VIEW 或 WITH_OUT_VIEW` });
727
+ if (bindAction === "SCRIPT" || bindAction === "BUSINESS_RULE" || bindAction === "FLOW" || bindAction === "ADD_DATA" || bindAction === "FORM") {
728
+ const actionConfig = button.actionConfig;
729
+ const record = typeof actionConfig === "string" ? (() => { try {
730
+ return JSON.parse(actionConfig);
731
+ }
732
+ catch {
733
+ return undefined;
734
+ } })() : actionConfig;
735
+ const config = record && typeof record === "object" && !Array.isArray(record) ? record : undefined;
736
+ if (bindAction === "SCRIPT" && typeof config?.scriptData !== "string")
737
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}] SCRIPT 需要 actionConfig.scriptData(前端脚本源码字符串)` });
738
+ if ((bindAction === "BUSINESS_RULE" || bindAction === "FLOW") && (typeof button.targetCode !== "string" || button.targetCode === ""))
739
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: buttons[${index}] ${bindAction} 需要 targetCode` });
740
+ }
741
+ return {
742
+ ...button,
743
+ code,
744
+ name,
745
+ useLocation,
746
+ bindAction,
747
+ operateType,
748
+ // The platform stores actionConfig as a string; a caller's object is
749
+ // serialized once here (double-encoding breaks the renderer).
750
+ ...(button.actionConfig !== undefined && typeof button.actionConfig !== "string" ? { actionConfig: JSON.stringify(button.actionConfig) } : {}),
751
+ sortKey: typeof button.sortKey === "number" ? button.sortKey : index,
752
+ };
753
+ });
754
+ }
755
+ function decodeOptionsColumn(value) {
756
+ if (value === null || value === undefined || value === "")
757
+ return {};
758
+ if (typeof value === "object" && !Array.isArray(value))
759
+ return { ...value };
760
+ if (typeof value === "string") {
761
+ try {
762
+ const parsed = JSON.parse(value);
763
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
764
+ return { ...parsed };
765
+ }
766
+ catch {
767
+ // A corrupt options column must not silently drop other options.
768
+ }
769
+ }
770
+ return {};
771
+ }
772
+ /**
773
+ * Merge the caller's form buttons into the sheet `options` column. Returns the
774
+ * options JSON string to POST (undefined = leave the column untouched).
775
+ */
776
+ export function mergeFormOptionsWithButtons(existingOptions, input, tool) {
777
+ const hasButtons = input.buttons !== undefined;
778
+ const hasJson = input.customButtonJson !== undefined;
779
+ const removeCodes = input.removeButtonCodes ?? [];
780
+ if (hasButtons && hasJson)
781
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-ambiguous", message: `${tool}: buttons 与 customButtonJson 只能给一种(buttons 数组更好读;customButtonJson 用于原样透传读回值)` });
782
+ if (!hasButtons && !hasJson && removeCodes.length === 0)
783
+ return undefined;
784
+ let incoming = [];
785
+ if (hasButtons) {
786
+ incoming = validateFormButtons(input.buttons, tool);
787
+ }
788
+ else if (hasJson) {
789
+ const raw = String(input.customButtonJson);
790
+ let parsed;
791
+ try {
792
+ parsed = JSON.parse(raw);
793
+ }
794
+ catch {
795
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: customButtonJson 必须是 JSON 数组字符串` });
796
+ }
797
+ incoming = validateFormButtons(parsed, tool);
798
+ }
799
+ const options = decodeOptionsColumn(existingOptions);
800
+ // Default MERGE: an upsert by `code` so a caller can add or edit ONE button
801
+ // without re-sending the whole list (and without a read-modify-write race).
802
+ // `replace` restores the whole-list semantics.
803
+ const mode = input.buttonsMode ?? "merge";
804
+ const existing = readFormButtons(options) ?? [];
805
+ let final;
806
+ if (mode === "replace") {
807
+ final = incoming;
808
+ }
809
+ else {
810
+ const byCode = new Map(existing.map((button) => [String(button.code ?? ""), button]));
811
+ for (const button of incoming)
812
+ byCode.set(String(button.code), { ...(byCode.get(String(button.code)) ?? {}), ...button });
813
+ for (const code of removeCodes)
814
+ byCode.delete(String(code));
815
+ final = [...byCode.values()].map((button, index) => ({ ...button, sortKey: typeof button.sortKey === "number" ? button.sortKey : index }));
816
+ }
817
+ if (removeCodes.length > 0 && mode === "replace")
818
+ throw new ToolRefusalError({ status: "refused", blocker: "form-buttons-invalid", message: `${tool}: removeButtonCodes 只用于 buttonsMode:"merge"(replace 已用新列表整体替换)` });
819
+ options.customButtonJson = JSON.stringify(final);
820
+ return JSON.stringify(options);
821
+ }
822
+ /** The caller's buttons from a READ face (parsed array, or null when absent). */
823
+ export function readFormButtons(options) {
824
+ const record = decodeOptionsColumn(options);
825
+ const raw = record.customButtonJson;
826
+ if (typeof raw !== "string" || raw === "")
827
+ return null;
828
+ try {
829
+ const parsed = JSON.parse(raw);
830
+ return Array.isArray(parsed) ? parsed : null;
831
+ }
832
+ catch {
833
+ return null;
834
+ }
835
+ }
668
836
  export async function executeFormDraftWrite(session, args) {
669
837
  if (args.draftHtmlJson !== undefined) {
670
838
  assertHtmlRender(args.draftHtmlJson, { schemaCode: args.schemaCode, sheetCode: args.sheetCode, face: "draftHtmlJson" });
671
839
  }
672
840
  // Caller-provided layouts get the distribution-grade shape gate (options
673
841
  // objects, string viewJson cells, no child keys as rows) before any wire.
674
- if (args.draftViewJson !== undefined || args.draftAttributesJson !== undefined)
675
- assertCallerFormLayout(args);
676
842
  // Ticket 74 — refuse CALLER-PROVIDED empty layouts before any wire call.
677
843
  // The platform designer's Save clears draft faces to "[]"/"{}" (non-lossless);
678
844
  // re-writing that shape explicitly is how the 13-control loss happened.
@@ -723,6 +889,42 @@ export async function executeFormDraftWrite(session, args) {
723
889
  if (pre === null)
724
890
  throw new WireError("business", `form ${args.sheetCode} was created but is not readable`, 302024);
725
891
  }
892
+ //
893
+ // 1.3 of issue 2026-10-23: the platform's OWN readback can carry shapes this
894
+ // gate rejects (`child_column_in_rows` when a published layout lists child
895
+ // columns as rows; `layout_row_overflow` on the auto system row). Refusing a
896
+ // byte-identical round-trip makes "read → write back" impossible, so a face
897
+ // that matches the sheet's own published/draft face is allowed through with
898
+ // the violations reported as warnings. Caller-authored layouts stay strict.
899
+ const roundTripFaces = new Set();
900
+ let layoutWarnings = [];
901
+ if (args.draftViewJson !== undefined || args.draftAttributesJson !== undefined) {
902
+ const pre2 = pre;
903
+ const same = (provided, stored) => typeof provided === "string" && typeof stored === "string" && provided.trim() === stored.trim();
904
+ const viewRoundTrip = same(args.draftViewJson, pre2?.publishedViewJson) || same(args.draftViewJson, pre2?.draftViewJson);
905
+ const attributesRoundTrip = same(args.draftAttributesJson, pre2?.publishedAttributesJson) || same(args.draftAttributesJson, pre2?.draftAttributesJson);
906
+ const allProvidedAreRoundTrip = (args.draftViewJson === undefined || viewRoundTrip) && (args.draftAttributesJson === undefined || attributesRoundTrip);
907
+ if (!allProvidedAreRoundTrip && args.fromReadback === true) {
908
+ throw new ToolRefusalError({
909
+ status: "refused",
910
+ blocker: "form-layout-invalid",
911
+ schemaCode: args.schemaCode,
912
+ sheetCode: args.sheetCode,
913
+ message: "fromReadback:true 但本次布局与已存草稿/发布面不一致——读过之后又改过就必须按普通写面校验(去掉 fromReadback,或按 validation 修正)",
914
+ validation: formLayoutViolations(args),
915
+ });
916
+ }
917
+ if (allProvidedAreRoundTrip) {
918
+ layoutWarnings = formLayoutViolations(args);
919
+ if (args.draftViewJson !== undefined)
920
+ roundTripFaces.add("draftViewJson");
921
+ if (args.draftAttributesJson !== undefined)
922
+ roundTripFaces.add("draftAttributesJson");
923
+ }
924
+ else {
925
+ assertCallerFormLayout(args);
926
+ }
927
+ }
726
928
  // Field-level relation mappings are the source of truth; read the published
727
929
  // fields so the scaffold (or an existing draft) can carry them into the
728
930
  // matching form control, where the form renderer reads them at fill time.
@@ -856,6 +1058,11 @@ export async function executeFormDraftWrite(session, args) {
856
1058
  if (composed !== undefined)
857
1059
  assertFormulasSurvivedRebuild(composed, previousAttributes, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
858
1060
  }
1061
+ // Form toolbar buttons live on `options.customButtonJson`; the publish step
1062
+ // turns them into h_biz_button rows (triggerType=FORM). Merge rather than
1063
+ // replace so unrelated options (presetButtonManage, sheetPresetButtonManage)
1064
+ // survive.
1065
+ const mergedOptions = mergeFormOptionsWithButtons(pre?.options, { buttons: args.buttons, customButtonJson: args.customButtonJson, buttonsMode: args.buttonsMode, removeButtonCodes: args.removeButtonCodes }, "form.draft");
859
1066
  const payload = {
860
1067
  ...(pre ?? {}),
861
1068
  schemaCode: args.schemaCode,
@@ -863,6 +1070,7 @@ export async function executeFormDraftWrite(session, args) {
863
1070
  name: pre?.name ?? args.sheetCode,
864
1071
  sheetType: pre?.sheetType ?? 0,
865
1072
  published: false,
1073
+ ...(mergedOptions !== undefined ? { options: mergedOptions } : {}),
866
1074
  // Draft writes are full-row updates, but they must not destroy the
867
1075
  // currently published face. Publish promotion is a separate operation;
868
1076
  // preserving these columns keeps the published form usable if the draft
@@ -878,7 +1086,123 @@ export async function executeFormDraftWrite(session, args) {
878
1086
  const performed = await wireCall(session, { path: ENDPOINTS.sheetUpdate, method: "POST", body: payload });
879
1087
  const readback = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode } });
880
1088
  const sheet = (readback.data ?? {});
881
- return { performed: { errcode: performed.errcode }, readback: { hasDraft: sheet.existDraft === true || typeof sheet.draftViewJson === "string", ...(args.rebuild === true && previousAttributes ? { rebuildPreservedControls } : {}) } };
1089
+ const formButtons = readFormButtons(sheet.options);
1090
+ return {
1091
+ performed: { errcode: performed.errcode },
1092
+ readback: {
1093
+ hasDraft: sheet.existDraft === true || typeof sheet.draftViewJson === "string",
1094
+ ...(formButtons !== null ? { formButtons, formButtonCodes: formButtons.map((button) => String(button.code ?? "")) } : {}),
1095
+ ...(mergedOptions !== undefined ? { customButtonJsonWritten: true } : {}),
1096
+ ...(roundTripFaces.size > 0
1097
+ ? {
1098
+ layoutGate: "platform-roundtrip",
1099
+ layoutRoundTripFaces: [...roundTripFaces],
1100
+ layoutNote: "本次布局与平台读回的原样布局一致,形状告警只提示不拒绝(平台自身产出可能含子表列行/系统行超宽)",
1101
+ ...(layoutWarnings.length > 0 ? { layoutWarnings } : {}),
1102
+ }
1103
+ : {}),
1104
+ ...(args.rebuild === true && previousAttributes ? { rebuildPreservedControls } : {}),
1105
+ },
1106
+ };
1107
+ }
1108
+ /** Schema-wide FORM buttons (code -> row) — the set a workflow node can tick. */
1109
+ export async function readSchemaFormButtons(session, schemaCode) {
1110
+ const inventory = (await wireCall(session, { path: ENDPOINTS.systemGetCustomButtons, query: { schemaCode } })).data;
1111
+ const triggers = Array.isArray(inventory?.formCustomButtons) ? inventory.formCustomButtons : [];
1112
+ const byCode = {};
1113
+ for (const trigger of triggers) {
1114
+ const buttons = Array.isArray(trigger.buttonModels) ? trigger.buttonModels : [];
1115
+ for (const button of buttons) {
1116
+ const code = String(button.code ?? "");
1117
+ if (code !== "" && byCode[code] === undefined)
1118
+ byCode[code] = button;
1119
+ }
1120
+ }
1121
+ return byCode;
1122
+ }
1123
+ /**
1124
+ * Execute ONE form-toolbar button on ONE record — the exact request the portal
1125
+ * posts when that button is clicked (live 2026-10-23, Playwright capture,
1126
+ * AI-Platform 1.1.25):
1127
+ * POST /api/api/runtime/form/executeCustomButton
1128
+ * {bizButtonModel:<the h_biz_button row>, bizObjectList:[<full bizObject>],
1129
+ * reacquireDataFromDb:true}
1130
+ * Distinct from the list-row button face (`launcher/domain_service_runtime`
1131
+ * with `ids[]`): form buttons carry the whole record.
1132
+ */
1133
+ export async function executeFormRunButtonWrite(session, args) {
1134
+ const inventory = (await wireCall(session, { path: ENDPOINTS.systemGetCustomButtons, query: { schemaCode: args.schemaCode } })).data;
1135
+ const triggers = Array.isArray(inventory?.formCustomButtons) ? inventory.formCustomButtons : [];
1136
+ const trigger = triggers.find((row) => String(row.triggerCode ?? "") === args.sheetCode) ?? triggers[0];
1137
+ const buttons = Array.isArray(trigger?.buttonModels) ? trigger.buttonModels : [];
1138
+ if (buttons.length === 0)
1139
+ throw new ToolRefusalError({
1140
+ status: "refused",
1141
+ blocker: "form-button-absent",
1142
+ schemaCode: args.schemaCode,
1143
+ sheetCode: args.sheetCode,
1144
+ message: `表单 ${args.sheetCode} 没有自定义按钮(getCustomButtons 的 buttonModels 为空)——先用 form.publish 传 buttons(写 options.customButtonJson)生成 h_biz_button(triggerType=FORM)`,
1145
+ });
1146
+ const button = buttons.find((row) => String(row.code ?? "") === args.buttonCode);
1147
+ if (!button)
1148
+ throw new ToolRefusalError({
1149
+ status: "refused",
1150
+ blocker: "form-button-absent",
1151
+ schemaCode: args.schemaCode,
1152
+ sheetCode: args.sheetCode,
1153
+ buttonCode: args.buttonCode,
1154
+ available: buttons.map((row) => String(row.code ?? "")),
1155
+ message: `表单 ${args.sheetCode} 没有按钮 ${args.buttonCode};可用:${buttons.map((row) => String(row.code ?? "")).join("、")}`,
1156
+ });
1157
+ const loaded = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode, objectId: args.bizObjectId, isMobile: "false" } })).data;
1158
+ const bizObject = (loaded?.bizObject ?? null);
1159
+ if (!bizObject)
1160
+ throw new ToolRefusalError({
1161
+ status: "refused",
1162
+ blocker: "form-object-absent",
1163
+ schemaCode: args.schemaCode,
1164
+ bizObjectId: args.bizObjectId,
1165
+ message: `读不到记录 ${args.bizObjectId} 的 bizObject(runtime/form/load 无 bizObject)——先 data.load 核对 bizObjectId`,
1166
+ });
1167
+ // The portal flattens the org/person fields to id strings before posting
1168
+ // (form-detail's customButtonRule: `c.owner=(c.owner?.id)||""` etc.). Sending
1169
+ // the loaded objects makes the platform answer 10009
1170
+ // "Cannot deserialize value of type java.lang.String from Object value".
1171
+ const idOf = (value) => {
1172
+ if (typeof value === "string")
1173
+ return value;
1174
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1175
+ const id = value.id;
1176
+ return typeof id === "string" ? id : "";
1177
+ }
1178
+ return "";
1179
+ };
1180
+ const submitted = { ...bizObject };
1181
+ for (const key of ["owner", "creater", "modifier", "ownerDeptId", "createdDeptId"]) {
1182
+ if (submitted[key] !== undefined)
1183
+ submitted[key] = idOf(submitted[key]);
1184
+ }
1185
+ submitted.formMode = loaded?.formMode ?? "isRead";
1186
+ submitted.activityCode = loaded?.activityCode ?? null;
1187
+ const performed = await wireCall(session, {
1188
+ path: ENDPOINTS.runtimeFormExecuteCustomButton,
1189
+ method: "POST",
1190
+ body: { bizButtonModel: button, bizObjectList: [submitted], reacquireDataFromDb: true },
1191
+ });
1192
+ return {
1193
+ performed: { errcode: performed.errcode, buttonCode: args.buttonCode, bizObjectId: args.bizObjectId },
1194
+ readback: {
1195
+ schemaCode: args.schemaCode,
1196
+ sheetCode: args.sheetCode,
1197
+ buttonCode: args.buttonCode,
1198
+ buttonName: String(button.name ?? ""),
1199
+ bindAction: String(button.bindAction ?? ""),
1200
+ targetCode: button.targetCode ?? null,
1201
+ errmsg: performed.data?.errmsg ?? null,
1202
+ ...(performed.errcode === 302045 ? { dataInUse: true, hint: "302045:数据被占用(有人正在编辑该行),稍后重试" } : {}),
1203
+ effectHint: "平台只回执行结果;按钮绑定的规则副作用请另读 data.load。表单按钮走 executeCustomButton(带整条记录),与列表按钮的 launcher 路径不同",
1204
+ },
1205
+ };
882
1206
  }
883
1207
  /**
884
1208
  * Shared form.publish write body — promotes the saved draft to published,
@@ -913,7 +1237,14 @@ export async function executeFormPublishWrite(session, args) {
913
1237
  throw new WireError("business", `form ${args.sheetCode} not found`, 302024);
914
1238
  if (pre.draftHtmlJson !== "[]")
915
1239
  assertHtmlRender(pre.draftHtmlJson, { schemaCode: args.schemaCode, sheetCode: args.sheetCode, face: "draftHtmlJson" });
916
- const forcePromote = args.force === true || args.rebuild === true;
1240
+ // A button-only publish on an ALREADY published form must still promote: the
1241
+ // platform materialises h_biz_button rows from options.customButtonJson during
1242
+ // the publish call, so short-circuiting "already-published" would silently
1243
+ // drop the buttons (live 2026-10-23).
1244
+ const hasButtonDelta = args.buttons !== undefined ||
1245
+ args.customButtonJson !== undefined ||
1246
+ (Array.isArray(args.removeButtonCodes) && args.removeButtonCodes.length > 0);
1247
+ const forcePromote = args.force === true || args.rebuild === true || hasButtonDelta;
917
1248
  if (pre.published === true && !forcePromote)
918
1249
  return { performed: { skipped: "already-published" }, readback: { published: true } };
919
1250
  if (!hasDraft(pre))
@@ -939,8 +1270,14 @@ export async function executeFormPublishWrite(session, args) {
939
1270
  });
940
1271
  }
941
1272
  }
1273
+ // Buttons ride `options.customButtonJson`; the platform converts them into
1274
+ // h_biz_button(triggerType=FORM) rows during THIS publish call
1275
+ // (ModelManagementFacadeImpl.publishBizForm:1022). Passing them here is the
1276
+ // only way to create form buttons without the designer.
1277
+ const mergedOptions = mergeFormOptionsWithButtons(pre.options, { buttons: args.buttons, customButtonJson: args.customButtonJson, buttonsMode: args.buttonsMode, removeButtonCodes: args.removeButtonCodes }, "form.publish");
942
1278
  const payload = {
943
1279
  ...pre,
1280
+ ...(mergedOptions !== undefined ? { options: mergedOptions } : {}),
944
1281
  schemaCode: args.schemaCode,
945
1282
  code: args.sheetCode,
946
1283
  name: pre.name,
@@ -961,6 +1298,20 @@ export async function executeFormPublishWrite(session, args) {
961
1298
  const performed = await wireCall(session, { path: ENDPOINTS.sheetPublish, method: "POST", body: payload });
962
1299
  const readback = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode: args.sheetCode } });
963
1300
  const sheet = (readback.data ?? {});
964
- return { performed: { errcode: performed.errcode }, readback: { published: sheet.published === true, publishedHtmlJsonPresent: typeof sheet.publishedHtmlJson === "string" } };
1301
+ const formButtons = readFormButtons(sheet.options);
1302
+ return {
1303
+ performed: { errcode: performed.errcode },
1304
+ readback: {
1305
+ published: sheet.published === true,
1306
+ publishedHtmlJsonPresent: typeof sheet.publishedHtmlJson === "string",
1307
+ ...(formButtons !== null
1308
+ ? {
1309
+ formButtons,
1310
+ formButtonCodes: formButtons.map((button) => String(button.code ?? "")),
1311
+ formButtonNote: "按钮已写入 options.customButtonJson,本次 publish 已生成 h_biz_button(triggerType=FORM);表单页是否显示还取决于流程节点 buttonPermission.customBtns[].enabled(设计器「表单操作」勾选)",
1312
+ }
1313
+ : {}),
1314
+ },
1315
+ };
965
1316
  }
966
1317
  //# sourceMappingURL=form-support.js.map
@@ -168,6 +168,10 @@ function phraseOf(tool, args, outcome) {
168
168
  return `发布流程 ${stringArg(args, "code") ?? "?"}(${schemaCode ?? "?"})`;
169
169
  case "workflow.bindRule": {
170
170
  const codes = Array.isArray(args.ruleCodes) ? args.ruleCodes.join("、") : stringArg(args, "ruleCode") ?? "?";
171
+ const activityCode = stringArg(args, "activityCode");
172
+ const nodeEvent = stringArg(args, "nodeEvent");
173
+ if (activityCode !== undefined && nodeEvent !== undefined)
174
+ return `绑定流程节点事件规则:${stringArg(args, "workflowCode") ?? "?"} 的节点 ${activityCode}.${nodeEvent} ← ${codes}`;
171
175
  return `绑定流程事件规则:${stringArg(args, "workflowCode") ?? "?"} 的 ${stringArg(args, "handler") ?? "end"} 处理器 ← ${codes}`;
172
176
  }
173
177
  case "workflow.delete":
@@ -220,7 +220,14 @@ export function registerBusinessRuleLane(tools) {
220
220
  schemaCode: record.schemaCode,
221
221
  bizRuleType: record.bizRuleType,
222
222
  enabled: record.enabled,
223
+ // The platform's `status` column lags behind the enable switch: r10
224
+ // saw `enabled:true` with `ruleStatus:"DISABLED"` while rule.list said
225
+ // ENABLED. `enabled` is the dispatch truth; flag the disagreement
226
+ // instead of letting two faces contradict each other silently.
223
227
  ruleStatus: record.status,
228
+ ...(record.enabled === true && String(record.status ?? "") !== "ENABLED"
229
+ ? { statusLag: true, statusNote: "平台 status 列滞后:enabled=true 才是分发真相(列表面/日志可复核);不要据 ruleStatus=DISABLED 判定未启用" }
230
+ : {}),
224
231
  defaultRule: record.defaultRule,
225
232
  developmentMode: record.developmentMode,
226
233
  ruleVersion: record.ruleVersion,
@@ -453,14 +453,37 @@ function childFormulaBareReferences(formula, childFieldCodes) {
453
453
  * field (→ platform 50000 on submit). Field-aware via the child schema's
454
454
  * published fields; a caller-provided set avoids a duplicate fieldList read.
455
455
  */
456
+ /**
457
+ * 1.4 of issue 2026-10-23: `datarule.list` resolves a CHILD-table schema
458
+ * (bizdatarule/list answers rows) while `bizproperty/list?schemaCode=<child>`
459
+ * answers 601003 业务模型不存在 — the same schema code reads on one face and
460
+ * fails on the other. Do not let the field-metadata probe veto a write that the
461
+ * rule face itself accepts: return null (metadata unavailable) and let callers
462
+ * skip the field-level guards with a warning.
463
+ */
464
+ async function readPublishedFieldCodesOrNull(session, schemaCode) {
465
+ try {
466
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
467
+ return new Set(rows.map((row) => String(row.code ?? row.propertyCode ?? "")));
468
+ }
469
+ catch (error) {
470
+ if (error instanceof WireError && error.kind === "business" && (error.errcode === 601003 || error.errcode === 10011))
471
+ return null;
472
+ throw error;
473
+ }
474
+ }
456
475
  async function assertChildFormulaRefs(session, args, knownChildFields) {
457
476
  if (args.dataRuleType !== 2 || args.childRule !== true)
458
477
  return;
459
478
  const formula = type2FormulaText(args.options);
460
479
  if (formula === null)
461
480
  return;
462
- const codes = knownChildFields ??
463
- new Set(wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data).map((row) => String(row.code ?? row.propertyCode ?? "")));
481
+ const codes = knownChildFields === undefined ? await readPublishedFieldCodesOrNull(session, args.schemaCode) : knownChildFields;
482
+ // Metadata unavailable (child-table schema on a face that only answers the
483
+ // rule side): the bare-reference heuristic needs the field list, so skip it
484
+ // rather than refusing a write the platform accepts.
485
+ if (codes === null)
486
+ return;
464
487
  const bare = childFormulaBareReferences(formula, codes);
465
488
  if (bare.length === 0)
466
489
  return;
@@ -658,8 +681,8 @@ export async function syncFormConditionDataRules(session, schemaCode, fieldCode,
658
681
  const existing = await listDataRules(session, schemaCode);
659
682
  // The designer's rule rows live on the PUBLISHED face (like datarule.create's
660
683
  // own guard); a draft-only field cannot carry a designer-visible row yet.
661
- const published = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data)
662
- .some((row) => String(row.code ?? row.propertyCode ?? "") === fieldCode);
684
+ const publishedCodes = await readPublishedFieldCodesOrNull(session, schemaCode);
685
+ const published = publishedCodes === null ? true : publishedCodes.has(fieldCode);
663
686
  for (const key of changedKeys) {
664
687
  const dataRuleType = FORM_CONDITION_RULE_TYPES[key];
665
688
  const next = text(nextOptions, key);
@@ -726,12 +749,13 @@ export function registerDataRuleLane(tools) {
726
749
  assertDataRuleCheckType(args.dataRuleType, args.checkType);
727
750
  assertDataRuleTarget(args);
728
751
  const codes = dataRuleTargetCodes(args);
729
- const fields = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
730
- const published = new Set(fields.map((row) => String(row.code ?? row.propertyCode ?? "")));
752
+ const published = await readPublishedFieldCodesOrNull(session, args.schemaCode);
731
753
  await assertChildFormulaRefs(session, args, published);
732
- const missing = codes.filter((code) => !published.has(code));
733
- if (missing.length > 0)
734
- throw new ToolRefusalError({ status: "refused", blocker: "datarule-property-not-published", schemaCode: args.schemaCode, propertyCodes: codes, missing, message: `字段 ${args.schemaCode}.${missing.join(",")} 不在发布面上;拒绝 datarule.create POST` });
754
+ if (published !== null) {
755
+ const missing = codes.filter((code) => !published.has(code));
756
+ if (missing.length > 0)
757
+ throw new ToolRefusalError({ status: "refused", blocker: "datarule-property-not-published", schemaCode: args.schemaCode, propertyCodes: codes, missing, message: `字段 ${args.schemaCode}.${missing.join(",")} 不在发布面上;拒绝 datarule.create POST` });
758
+ }
735
759
  const existingRules = await listDataRules(session, args.schemaCode);
736
760
  // dataRuleType 1 (CHECK_RULE) fans out into 5 checkTypes that COEXIST on one
737
761
  // field (the platform auto-creates a checkType 4 maxLength row per text
@@ -15,11 +15,13 @@ export const FORM_WIRE = Object.freeze({
15
15
  publishAlreadyPublished: "published:true with force!==true skips as already-published",
16
16
  forceRepublish: "onlinejs.compose passes force:true so it can re-promote after a prior publish",
17
17
  emptyLayoutGuard: "ticket 74: the platform designer's Save is NOT lossless — it clears draftViewJson to \"[]\" and draftAttributesJson to \"{}\"; form.draft refuses CALLER-PROVIDED empty layouts (blocker form-empty-layout, zero wire, escape allowEmptyLayout:true) and form.publish refuses to promote a draft with fewer controls than the published face (blocker form-publish-degrade, escape allowDegrade:true); the recovery path is the form version history (/api/api/app/bizsheet/history/list) — fetch the intact version's view/attributes originals and rewrite the draft before publishing",
18
+ formButtons: "form toolbar buttons are a three-layer configuration (live 2026-10-23, 1.1.25): (1) bizsheet.options.customButtonJson — a STRINGIFIED JSON array — is the authoring face; ModelManagementFacadeImpl.publishBizForm:1022 turns it into h_biz_button rows with triggerType=FORM, triggerCode=<formCode> at publish; (2) those rows are what ActionPermissionHandler.getByTriggerTypeAndCode(FORM, bizCode) reads; (3) whether a button shows at a node is filtered by the workflow activity's buttonPermission.customBtns[].enabled (the designer 表单操作 checkboxes) plus showPermCode; the execute face is POST /api/api/runtime/form/executeCustomButton {bizButtonModel, bizObjectList:[<full bizObject>], reacquireDataFromDb:true} — NOT the list-button launcher path with ids[]",
18
19
  actionsColumns: "ticket 100: draftActionsJson/publishedActionsJson are REAL bizsheet columns (BizForm.java:95-100 @SerializableField; BizFormModel.java:59-62 serialized by bizsheet/get) — the designer writes draftActionsJson on save and both faces on publish (frontend-dist/admin/js/4280.cfa75764.js offsets 193538/185347); form.get full:true surfaces them; publish promotes draftActionsJson → publishedActionsJson",
19
20
  });
20
- const FORM_GET_DESCRIPTION = '读取默认表单(bizsheet/get)的发布状态与控件清单(紧凑默认)。**发布态以返回的 `published` / `existDraft` 布尔为准**(`published:true`=已发布;`published:false,existDraft:true`=只有草稿,**未发布**)——不要用 `publishedControlCodes` 是否非空来推断(草稿字段会让它非空,误判为已发布)。**每个模型交付前其默认表单必须 `published:true`**:用 `configure_form` 或 `form.draft(rebuild:true)`+`form.publish` 发布,并逐模型 `form.get` 核对。传 `controls:["code",…]` 只取指定控件的 options(如关联控件的 mappings);传 `full:true` 才返回 draft/published 的完整 view/attributes/html/actions JSON(actions 列为设计器保存/发布的表单级按钮动作)。体积大,仅在需要整体布局时用。';
21
- const FORM_DRAFT_DESCRIPTION = "Save the default-form DRAFT (bizsheet/update). Reads the current sheet, merges the caller's explicit draftViewJson/draftAttributesJson (or preserves the existing draft); when both are absent on a fresh default form, composes the designer-standard scaffold from the model's published fields. A redundant field (relation-mapping carry-through target) always stays bound; with no workflow it is hidden in the design (options.visible=false), with a workflow it stays visible for per-node propertyPermissions. Pass `draftHtmlJson` to thread custom online-script HTML through. Empty-layout guard: explicitly passing \"[]\"/\"{}\" view/attributes is refused (form-empty-layout) — the platform designer's Save is NOT lossless and clears drafts to exactly that shape; escape with allowEmptyLayout:true, and recover cleared drafts from the form version history (/api/api/app/bizsheet/history/list). **表单控件属性(含关联填充 mappings)从字段行派生**:改关联映射的事实源在字段 options.mappings(field.update → field.publish),直接改这里的控件映射会被平台按字段重算覆盖(真机 2026-10-14 实证);字段发布后如需刷新已发布表单的控件映射,用本工具 rebuild:false 重存(保留现有布局与在线JS)再 form.publish。";
22
- const FORM_PUBLISH_DESCRIPTION = "Publish the default form (bizsheet/publish, proven shape: draft*Json promoted to published*Json verbatim) with published-face readback. Degrade guard: refuses to promote a draft carrying FEWER controls than the published face (form-publish-degrade) — the designer's Save clears drafts non-losslessly; recover via the form version history (/api/api/app/bizsheet/history/list) and rewrite the draft first, or pass allowDegrade:true to force the reduction. rebuild:true is the one-call refresh: discard the existing draft, re-scaffold from the published fields (non-structural control options preserved), then force-promote — use it after a field shape change.";
21
+ const FORM_GET_DESCRIPTION = '读取默认表单(bizsheet/get)的发布状态与控件清单(紧凑默认)。**发布态以返回的 `published` / `existDraft` 布尔为准**(`published:true`=已发布;`published:false,existDraft:true`=只有草稿,**未发布**)——不要用 `publishedControlCodes` 是否非空来推断(草稿字段会让它非空,误判为已发布)。**每个模型交付前其默认表单必须 `published:true`**:用 `configure_form` 或 `form.draft(rebuild:true)`+`form.publish` 发布,并逐模型 `form.get` 核对。`full:true` 还返回 `options` 与解析后的 `customButtonJson`(表单工具栏按钮)。传 `controls:["code",…]` 只取指定控件的 options(如关联控件的 mappings);传 `full:true` 才返回 draft/published 的完整 view/attributes/html/actions JSON(actions 列为设计器保存/发布的表单级按钮动作)。体积大,仅在需要整体布局时用。';
22
+ const FORM_DRAFT_DESCRIPTION = "Save the default-form DRAFT (bizsheet/update). Reads the current sheet, merges the caller's explicit draftViewJson/draftAttributesJson (or preserves the existing draft); when both are absent on a fresh default form, composes the designer-standard scaffold from the model's published fields. A redundant field (relation-mapping carry-through target) always stays bound; with no workflow it is hidden in the design (options.visible=false), with a workflow it stays visible for per-node propertyPermissions. Pass `draftHtmlJson` to thread custom online-script HTML through. Empty-layout guard: explicitly passing \"[]\"/\"{}\" view/attributes is refused (form-empty-layout) — the platform designer's Save is NOT lossless and clears drafts to exactly that shape; escape with allowEmptyLayout:true, and recover cleared drafts from the form version history (/api/api/app/bizsheet/history/list). **读回原样回写**:从 `form.get` 取回的 view/attributes 直接回传时加 `fromReadback:true`——工具会与已存草稿/发布面逐字比对,一致才放行(平台自身布局可能含子表列行/系统行默认宽度),被改动过的面照常按 validation 拒绝。**表单工具栏按钮**:`buttons`(数组)或 `customButtonJson`(原样字符串)写进 `biz_sheet.options.customButtonJson`;**要到 form.publish 才生成 h_biz_button(triggerType=FORM)**,且表单页是否显示还取决于流程节点 `buttonPermission.customBtns[].enabled`(设计器「表单操作」勾选)。`useLocation` 用 FORM/ROW/LIST,表单工具栏按钮用 FORM。 **表单控件属性(含关联填充 mappings)从字段行派生**:改关联映射的事实源在字段 options.mappings(field.update → field.publish),直接改这里的控件映射会被平台按字段重算覆盖(真机 2026-10-14 实证);字段发布后如需刷新已发布表单的控件映射,用本工具 rebuild:false 重存(保留现有布局与在线JS)再 form.publish。";
23
+ const FORM_PUBLISH_DESCRIPTION = "Publish the default form (bizsheet/publish, proven shape: draft*Json promoted to published*Json verbatim) with published-face readback. Degrade guard: refuses to promote a draft carrying FEWER controls than the published face (form-publish-degrade) — the designer's Save clears drafts non-losslessly; recover via the form version history (/api/api/app/bizsheet/history/list) and rewrite the draft first, or pass allowDegrade:true to force the reduction. rebuild:true is the one-call refresh: discard the existing draft, re-scaffold from the published fields (non-structural control options preserved), then force-promote — use it after a field shape change. **表单工具栏按钮**:传 `buttons`(或 `customButtonJson`)→ 写进 `options.customButtonJson` 并在本次 publish 生成 `h_biz_button(triggerType=FORM, triggerCode=<formCode>)`(平台 `publishBizForm:1022`);`options.customButtonJson` 必须是**字符串化数组**(对象会 50000 syntax error);表单页是否显示再由流程节点 `buttonPermission.customBtns[].enabled` 过滤。";
24
+ const FORM_RUN_BUTTON_DESCRIPTION = "执行表单工具栏按钮(平台同款面:`POST /api/api/runtime/form/executeCustomButton`,body `{bizButtonModel, bizObjectList:[<完整业务对象>], reacquireDataFromDb:true}`)。**与列表行按钮不同**:列表按钮发 `launcher/domain_service_runtime/<schema>/<rule>` + `ids[]`(用 `rule.run`),表单按钮带**整条记录**,不能互换。按钮来源 `GET /api/api/system/getCustomButtons`(只列 triggerType=FORM 的 h_biz_button)。前提:按钮已由 `form.publish` 的 `buttons`/`customButtonJson` 生成。返回是执行结果信封,规则副作用用 `data.load` 另读;被规则 UPDATE 的目标行须是 COMPLETED(引擎查不到 DRAFT)。`302045` = 数据被占用。";
23
25
  const CONFIGURE_FORM_DESCRIPTION = "High-level: bind fields into the default form and publish it (form.draft → form.publish), subject to the write permission gate. Delegates to the proven form.draft scaffold + form.publish paths so the real empty-form layout (and any caller-supplied draftHtmlJson) is persisted and promoted — never bypasses with a shortcut body. `sheetCode` is optional: omitted means the model's default sheet (sheetCode = schemaCode).";
24
26
  export const FORM_LANE_CONTRACT = Object.freeze({
25
27
  lane: LANES.form,
@@ -42,6 +44,7 @@ export const FORM_LANE_CONTRACT = Object.freeze({
42
44
  forceRepublish: FORM_WIRE.forceRepublish,
43
45
  emptyLayoutGuard: FORM_WIRE.emptyLayoutGuard,
44
46
  actionsColumns: FORM_WIRE.actionsColumns,
47
+ formButtons: FORM_WIRE.formButtons,
45
48
  }),
46
49
  wireBindings: Object.freeze({
47
50
  draftBody: Object.freeze([behaviorBinding("test/form-scaffold.test.ts#form.draft scaffolds an empty default form from published fields", assertFormContract)]),
@@ -54,6 +57,7 @@ export const FORM_LANE_CONTRACT = Object.freeze({
54
57
  forceRepublish: Object.freeze([behaviorBinding("test/issue-11-online-js.test.ts#onlinejs.compose merges customScript into the existing draftHtmlJson layout", assertFormContract)]),
55
58
  emptyLayoutGuard: Object.freeze([behaviorBinding("test/ticket-74-form-empty-layout.test.ts#ticket 74: form.draft refuses an explicitly empty draftViewJson", assertFormContract)]),
56
59
  actionsColumns: Object.freeze([behaviorBinding("test/ticket-100-form-actions-columns.test.ts#form.get full:true surfaces draftActionsJson/publishedActionsJson", assertFormContract)]),
60
+ formButtons: Object.freeze([behaviorBinding("test/form-buttons.test.ts#form.publish with buttons materialises FORM-trigger buttons and form.get surfaces them", assertFormContract)]),
57
61
  }),
58
62
  evidence: Object.freeze([
59
63
  ".scratch/omc-route-wayfinder/leg2-closeout.md",
@@ -65,6 +69,7 @@ export const FORM_LANE_CONTRACT = Object.freeze({
65
69
  "form.draft": Object.freeze({ scope: "draft publish publishedHtmlJson readback", description: FORM_DRAFT_DESCRIPTION }),
66
70
  "form.publish": Object.freeze({ scope: "draft publish publishedHtmlJson readback", description: FORM_PUBLISH_DESCRIPTION }),
67
71
  configure_form: Object.freeze({ scope: "draft publish publishedHtmlJson readback", description: CONFIGURE_FORM_DESCRIPTION }),
72
+ "form.runButton": Object.freeze({ scope: "form toolbar button execution", description: FORM_RUN_BUTTON_DESCRIPTION }),
68
73
  }),
69
74
  });
70
75
  export function assertFormContract() {
@@ -74,7 +79,7 @@ export function assertFormContract() {
74
79
  assertDescriptionCarries(FORM_LANE_CONTRACT, "form.get", ["bizsheet/get", "draft/published", "mappings", "`full:true`", "actions"]);
75
80
  if (!FORM_WIRE.actionsColumns.includes("BizForm.java:95-100"))
76
81
  throw new Error("form contract must pin the actions-columns evidence (ticket 100)");
77
- assertDescriptionCarries(FORM_LANE_CONTRACT, "form.draft", ["bizsheet/update", "draftViewJson", "draftAttributesJson", "scaffold", "draftHtmlJson", "form-empty-layout"]);
82
+ assertDescriptionCarries(FORM_LANE_CONTRACT, "form.draft", ["bizsheet/update", "draftViewJson", "draftAttributesJson", "scaffold", "draftHtmlJson", "form-empty-layout", "fromReadback"]);
78
83
  if (!FORM_WIRE.redundantFieldVisibility.includes("propertyPermissions"))
79
84
  throw new Error("form contract must carry the redundant-field visibility (propertyPermissions) fact");
80
85
  assertDescriptionCarries(FORM_LANE_CONTRACT, "form.publish", ["bizsheet/publish", "draft*Json", "published*Json", "verbatim", "form-publish-degrade", "history/list", "rebuild:true", "force-promote"]);
@@ -93,5 +98,10 @@ export function assertFormContract() {
93
98
  throw new Error("form contract must pin the force-republish override");
94
99
  if (!FORM_WIRE.emptyLayoutGuard.includes("form-empty-layout") || !FORM_WIRE.emptyLayoutGuard.includes("form-publish-degrade"))
95
100
  throw new Error("form contract must pin the empty-layout/degrade guards");
101
+ if (!FORM_WIRE.formButtons.includes("customButtonJson") || !FORM_WIRE.formButtons.includes("executeCustomButton") || !FORM_WIRE.formButtons.includes("publishBizForm:1022"))
102
+ throw new Error("form contract must pin the form-button three-layer mechanism");
103
+ assertDescriptionCarries(FORM_LANE_CONTRACT, "form.draft", ["buttons", "customButtonJson", "h_biz_button", "buttonPermission"]);
104
+ assertDescriptionCarries(FORM_LANE_CONTRACT, "form.publish", ["buttons", "customButtonJson", "triggerType=FORM", "buttonPermission"]);
105
+ assertDescriptionCarries(FORM_LANE_CONTRACT, "form.runButton", ["executeCustomButton", "bizObjectList", "getCustomButtons", "rule.run", "302045"]);
96
106
  }
97
107
  //# sourceMappingURL=form-contract.js.map