@stalfh233/omc-cli 0.6.16 → 0.6.18

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.
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
- import { assertFieldFamilyAllowed, 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, resolveApplicationScope } from "../support.js";
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 resolveApplicationScope(session, args.applicationCode);
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
- return { node: (await resolveModelTreeNode(session, schemaCode)).node };
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
- const fieldRows = Array.isArray(fieldFace?.fields) ? fieldFace.fields : [];
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 applicationCode = await resolveApplicationCode(session, args.applicationCode);
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
- applicationCode: existing.applicationCode,
544
- message: `模型 ${args.code} 已存在于应用 ${existing.applicationCode} 的应用树中;create 拒绝重复 POST,不能把既有模型证明为本次创建结果`,
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
@@ -1317,6 +1322,8 @@ export function registerModelLane(tools) {
1317
1322
  ];
1318
1323
  if (violations.length > 0)
1319
1324
  throw new ToolRefusalError({ status: "refused", blocker: "business-code-invalid", message: "code 未过出门前校验;按 validation 修正(长度/风格/保留字)", validation: violations });
1325
+ for (const entry of entries)
1326
+ assertSelectionChoicesAllowed(entry);
1320
1327
  for (const entry of entries)
1321
1328
  prepareFieldChange(fieldChangeInputOf(args.code, entry));
1322
1329
  },
@@ -1356,6 +1363,12 @@ export function registerModelLane(tools) {
1356
1363
  execute: async (session, args) => {
1357
1364
  const results = [];
1358
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);
1359
1372
  const stop = (index, tool, error) => {
1360
1373
  const envelope = error instanceof ToolRefusalError
1361
1374
  ? Object.fromEntries(Object.entries(error.payload).filter(([key]) => key !== "status"))
@@ -1378,7 +1391,8 @@ export function registerModelLane(tools) {
1378
1391
  return stop(index, tool, error);
1379
1392
  }
1380
1393
  };
1381
- const applicationCode = await resolveApplicationCode(session, args.applicationCode);
1394
+ const applicationScope = await resolveValidatedApplicationScope(session, args.applicationCode);
1395
+ const applicationCode = applicationScope.code;
1382
1396
  const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
1383
1397
  const folder = args.folderId ? tree.find((row) => row.id === args.folderId) : tree.find((row) => row.type !== "BizModel" && typeof row.id === "string");
1384
1398
  if (!folder?.id)
@@ -1389,6 +1403,10 @@ export function registerModelLane(tools) {
1389
1403
  // or form step. The application tree is already in hand, so an existing
1390
1404
  // model is detected with no extra call: skip the create and continue.
1391
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;
1392
1410
  let failed;
1393
1411
  if (existingModel) {
1394
1412
  results.push({ step: "model.create", skipped: "model-exists", folderId: String(folder.id), readback: { present: true } });
@@ -1405,6 +1423,7 @@ export function registerModelLane(tools) {
1405
1423
  return failed;
1406
1424
  }
1407
1425
  const existingFields = new Map();
1426
+ const mergedFieldCodes = [];
1408
1427
  for (const isPublish of ["false", "true"]) {
1409
1428
  for (const row of wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.code, isPublish } })).data))
1410
1429
  existingFields.set(String(row.code ?? row.propertyCode ?? ""), row);
@@ -1415,8 +1434,10 @@ export function registerModelLane(tools) {
1415
1434
  failed = await run(results.length, "field.create", async () => {
1416
1435
  const existing = existingFields.get(entry.code);
1417
1436
  if (existing !== undefined) {
1418
- if (sameFieldChange(existing, input))
1437
+ if (sameFieldChange(existing, input)) {
1438
+ mergedFieldCodes.push(entry.code);
1419
1439
  return { step: "field.create", skipped: "field-exists", code: entry.code, readback: { present: true } };
1440
+ }
1420
1441
  throw new ToolRefusalError({
1421
1442
  status: "refused",
1422
1443
  blocker: "field-conflict",
@@ -1430,6 +1451,7 @@ export function registerModelLane(tools) {
1430
1451
  const after = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.code, isPublish: "false" } })).data);
1431
1452
  if (!after.some((row) => String(row.code ?? row.propertyCode) === entry.code))
1432
1453
  throw new ToolRefusalError({ status: "refused", blocker: "field-create-readback-failed", message: `field.create 后权威草稿字段未读到 ${entry.code}` });
1454
+ mergedFieldCodes.push(entry.code);
1433
1455
  return { step: "field.create", errcode: performed.errcode, code: entry.code, readback: { present: true } };
1434
1456
  });
1435
1457
  if (failed)
@@ -1482,6 +1504,8 @@ export function registerModelLane(tools) {
1482
1504
  const rows = wireRows(published.data);
1483
1505
  const publishedCodes = rows.map((row) => String(row.code ?? row.propertyCode ?? ""));
1484
1506
  const readback = {
1507
+ created,
1508
+ ...(created ? {} : { mergedChanges: { fields: mergedFieldCodes, nameIgnored: args.name !== String(existingModel?.name ?? args.name) } }),
1485
1509
  publishedCodes,
1486
1510
  ...(entries.length === 1
1487
1511
  ? { publishedFieldPresent: publishedCodes.includes(entries[0].code) }
@@ -1513,8 +1537,10 @@ function fieldEntriesOf(args) {
1513
1537
  if (Array.isArray(args.fields) && args.fields.length > 0) {
1514
1538
  if (args.fieldCode !== undefined || args.fieldName !== undefined || args.propertyType !== undefined)
1515
1539
  throw new ToolRefusalError({ status: "refused", blocker: "configure-model-fields-exclusive", message: "fields[] 与单字段参数(fieldCode/fieldName/propertyType)互斥;多字段用 fields[],单字段用旧三元组" });
1516
- for (const entry of args.fields)
1540
+ for (const entry of args.fields) {
1541
+ assertSelectionChoicesAllowed(entry);
1517
1542
  assertFieldFamilyAllowed({ ...entry });
1543
+ }
1518
1544
  return args.fields.map((entry) => ({ code: entry.code, name: entry.name, propertyType: entry.propertyType, ...withLegacy(entry) }));
1519
1545
  }
1520
1546
  if (typeof args.fieldCode !== "string" || typeof args.fieldName !== "string" || typeof args.propertyType !== "string")
@@ -1524,6 +1550,7 @@ function fieldEntriesOf(args) {
1524
1550
  message: "configure_model 需要 fields[](多字段)或 fieldCode+fieldName+propertyType(单字段)",
1525
1551
  });
1526
1552
  const legacy = { code: args.fieldCode, name: args.fieldName, propertyType: args.propertyType, ...withLegacy(args) };
1553
+ assertSelectionChoicesAllowed(legacy);
1527
1554
  assertFieldFamilyAllowed(legacy);
1528
1555
  return [legacy];
1529
1556
  }
@@ -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 + 立即发布整张表单(等同 form.publish,含布局与按钮)**:它把 source 写进 customScript,然后无条件下一次 bizsheet/publish 把整份草稿提拔成发布面(内部 force:true,即使表单此前已发布也会重推)。所以改完 JS **不需要**再单独 form.publish;反过来,form.publish 只提拔布局草稿、**不碰**在线 JS。要只改不发布必须显式传 publish:false。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.`,
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
  });
@@ -77,7 +77,7 @@ export function registerOnlineJsLane(tools) {
77
77
  return {
78
78
  schemaCode: args.schemaCode,
79
79
  sheetCode,
80
- present: true,
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().min(1).describe("在线 JS 代码。默认 INCREMENTAL:并入平台 (function(form){…}) 模板的既有脚本,模板与既有注册全部保留;配 replace:true 才是整段替换。脚本不得使用 export/import;事件名必须是平台事件(onLoad/onRendered/onValidate/onPreAction/onCustomAction/onActionDone/onPrintFormDataHandle/onPrintWorkflowLogDataHandle)"),
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
- publish: z.boolean().optional(),
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 = { draft: validateHtmlRender(composedDraftHtml, true), published: validateHtmlRender(publishedRaw, true) };
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) => lintOnlineJsSource(args.source),
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 composed = composeCustomScript(existingScript, args.source, args.replace === true ? "replace" : "merge");
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: Buffer.byteLength(composed, "utf8"),
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: "irreversible", reason: "before 表单没有可识别的 customScript element,无法构造清空脚本的等价 compose 调用" };
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
  },
@@ -531,10 +531,17 @@ export function registerOrganizationLane(tools) {
531
531
  remarks: z.string().optional(),
532
532
  corpId: z.string().min(1).optional().describe("组织 corpId,默认当前登录企业;传 \"other\" 表示非组织分组角色"),
533
533
  },
534
- execute: async (session, args) => {
534
+ preflight: (args) => {
535
535
  if (!args.roleGroupCode && !args.roleGroupId) {
536
- throw new ToolInputError({ message: "必须给 roleGroupCode(推荐)或 roleGroupId;先 org.role.list 取 groups 码表" });
536
+ throw new ToolRefusalError({
537
+ status: "refused",
538
+ blocker: "role-group-required",
539
+ nextCommand: "omc call org.role.list --args '{}'",
540
+ message: "必须给 roleGroupCode(推荐)或 roleGroupId;先 org.role.list 取 groups 码表",
541
+ });
537
542
  }
543
+ },
544
+ execute: async (session, args) => {
538
545
  const { groups } = await readRoleTree(session);
539
546
  if (groups.length === 0) {
540
547
  throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "org.role.list 未解析出任何角色组;先确认组织树可读,不要盲建角色" });
@@ -7,6 +7,7 @@ import { writeTool } from "../write.js";
7
7
  import { defineTool, ToolRefusalError } from "../tool-types.js";
8
8
  import { wireCall, WireError } from "../wire.js";
9
9
  import { PROGRAMMING_LANE_CONTRACT, PROGRAMMING_WIRE } from "./programming-contract.js";
10
+ import { readModelRecord } from "../model-record.js";
10
11
  const LANE = PROGRAMMING_LANE_CONTRACT.lane;
11
12
  const TOOLS = PROGRAMMING_LANE_CONTRACT.tools;
12
13
  /** metadata.app.CodeType is the NUMERIC index on the wire (8.6.26 source). */
@@ -60,7 +61,8 @@ async function readSource(session, args) {
60
61
  const model = asRecord(response.data);
61
62
  if (model === null)
62
63
  return { present: false, provisioned: false, model: null };
63
- return { present: true, provisioned: typeof model.version === "number", model };
64
+ // get_source can return a generated template without a persisted source row.
65
+ return { present: typeof model.version === "number", provisioned: typeof model.version === "number", model };
64
66
  }
65
67
  catch (error) {
66
68
  if (error instanceof WireError && error.kind === "business" && /PROGRAMMING_SOURCE_NONEXISTENT|源码不存在|source.*nonexistent/i.test(error.message))
@@ -102,6 +104,13 @@ function assertTemplateShape(content, code) {
102
104
  });
103
105
  }
104
106
  }
107
+ function assertProgrammingName(name) {
108
+ if (name === undefined)
109
+ return;
110
+ if (typeof name !== "string" || !/^[A-Za-z][A-Za-z0-9_ -]{0,127}$/.test(name)) {
111
+ throw new ToolRefusalError({ status: "refused", blocker: "programming-name-invalid", message: "businessRuleName 只能以字母开头,并只含字母、数字、下划线、空格或连字符(最多 128 字符)。" });
112
+ }
113
+ }
105
114
  async function listRules(session, schemaCode) {
106
115
  const response = await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode } });
107
116
  return rowsOf(response.data);
@@ -223,6 +232,17 @@ function sourceRollback() {
223
232
  },
224
233
  };
225
234
  }
235
+ function publishRollback() {
236
+ return {
237
+ target: (args) => `programming.publish:${String(args.schemaCode ?? "")}/${String(args.businessRuleCode ?? "")}`,
238
+ // A published version has no authoritative read face and cannot be
239
+ // restored by replaying the draft. Deliberately fail before-capture so the
240
+ // shared journal records rollbackable:false rather than implying a draft
241
+ // restore would undo the runtime version.
242
+ read: async () => { throw new Error("published version has no rollbackable read face"); },
243
+ inverse: () => ({ kind: "irreversible", reason: "无图形化回退端点;删除规则才会连源码一起删" }),
244
+ };
245
+ }
226
246
  export function registerProgrammingLane(tools) {
227
247
  tools.push(defineTool({
228
248
  name: "programming.schemas",
@@ -310,12 +330,14 @@ export function registerProgrammingLane(tools) {
310
330
  },
311
331
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
312
332
  const read = await readSource(session, args);
333
+ const model = await readModelRecord(session, args.schemaCode);
313
334
  const content = typeof read.model?.content === "string" ? read.model.content : null;
314
335
  return {
315
336
  schemaCode: args.schemaCode,
316
337
  businessRuleCode: args.businessRuleCode,
317
338
  code: String(read.model?.code ?? args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode)),
318
339
  present: read.present,
340
+ modelExists: model !== null,
319
341
  provisioned: read.provisioned,
320
342
  className: read.model?.className ?? null,
321
343
  codeType: read.model?.codeType ?? null,
@@ -326,13 +348,15 @@ export function registerProgrammingLane(tools) {
326
348
  contentBytes: content === null ? null : Buffer.byteLength(content, "utf8"),
327
349
  // r28j F7: --search "bizObject" 被写危害文档淹没;API 骨架入口钉在这里。
328
350
  apiHint: 'BizObject 读写骨架:omc help omc-backend-online-coding --section "5. 典型 handler 骨架"',
329
- ...(read.provisioned
330
- ? {
331
- // Draft face always reports published:false after publish too —
332
- // do not let callers treat this boolean as the publish verdict (F11).
333
- publishedNote: "本面 published 来自 version=0 草稿行;发布是否生效请看 programming.publish 的 readback.runtimeEffective / developmentMode+enabled,不要看本字段。",
334
- }
335
- : { hint: "未落库草稿:content 是平台按规则类型生成的模板骨架(version:null)。改完后 programming.save 落库,programming.publish 生效。" }),
351
+ ...(model === null
352
+ ? { hint: `模型 ${args.schemaCode} 不存在;get_source 返回的只是平台模板骨架,不代表源码或模型存在。先用 model.list 核对 schemaCode。` }
353
+ : read.provisioned
354
+ ? {
355
+ // Draft face always reports published:false after publish too —
356
+ // do not let callers treat this boolean as the publish verdict (F11).
357
+ publishedNote: "本面 published 来自 version=0 草稿行;发布是否生效请看 programming.publish 的 readback.runtimeEffective / developmentMode+enabled,不要看本字段。",
358
+ }
359
+ : { hint: "未落库草稿:content 是平台按规则类型生成的模板骨架(version:null)。改完后 programming.save 落库,programming.publish 生效。" }),
336
360
  };
337
361
  }, {
338
362
  target: `programming.get:${args.schemaCode}/${args.businessRuleCode}`,
@@ -442,10 +466,20 @@ export function registerProgrammingLane(tools) {
442
466
  },
443
467
  preflight: (args) => {
444
468
  const code = args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode);
469
+ assertProgrammingName(args.businessRuleName);
445
470
  assertTemplateShape(args.content, code);
446
471
  },
447
- dryRunPayload: async (_session, args) => {
472
+ dryRunPayload: async (session, args) => {
448
473
  const code = args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode);
474
+ const rule = await ruleOf(session, args.schemaCode, args.businessRuleCode);
475
+ if (!rule) {
476
+ throw new ToolRefusalError({
477
+ status: "refused",
478
+ blocker: "programming-rule-absent",
479
+ message: `业务规则 ${args.schemaCode}/${args.businessRuleCode} 不存在;save 不会凭空创建规则行。先用 rule.save(或 rule.list)确认/创建规则,再保存源码。`,
480
+ nextCommand: `omc call rule.save --env local --args '{"schemaCode":"${args.schemaCode}","ruleCode":"${args.businessRuleCode}"}'`,
481
+ });
482
+ }
449
483
  return {
450
484
  code,
451
485
  contentSha256: sha256(args.content),
@@ -538,6 +572,8 @@ export function registerProgrammingLane(tools) {
538
572
  const publishStatusRaw = readbackRule?.publishStatus;
539
573
  return {
540
574
  performed: { errcode: 0, code, contentSha256: sha256(content), contentBytes: Buffer.byteLength(content, "utf8"), compiled: true },
575
+ rollbackReceipt: { supported: false, reason: "无图形化回退端点;rollback plan 亦标 irreversible" },
576
+ runtimeVerification: { note: "自定义规则无调用响应体读面;验证须写副作用后用 data.load 读取" },
541
577
  readback: {
542
578
  present: readbackSource.present,
543
579
  contentMatches: readbackContent === content,
@@ -554,19 +590,36 @@ export function registerProgrammingLane(tools) {
554
590
  // so it cannot contradict runtimeEffective:"published".
555
591
  ...(readbackSource.model?.published === true ? { publishedFlag: true } : { publishedFlagNote: "get_source.published 读的是 version=0 草稿行,发布后仍常为 false;不是发布成败判据(published 内容无 wire 读端点)" }),
556
592
  note: "运行时执行的是 published 最新版;published 内容无 wire 读取端点,回滚靠本地 git + 重新 publish。判发布是否生效:authoritative=developmentMode+enabled(1/CODING + true)。",
593
+ rollback: { supported: false, reason: "无图形化回退端点;删除规则才会连源码一起删" },
594
+ runtimeVerification: { note: "自定义规则无调用响应体读面;须写副作用(如字段赋值)后 data.load 验证" },
557
595
  },
558
596
  };
559
597
  },
560
598
  preflight: (args) => {
561
599
  const code = args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode);
600
+ assertProgrammingName(args.businessRuleName);
562
601
  if (args.content !== undefined)
563
602
  assertTemplateShape(args.content, code);
564
603
  },
565
- dryRunPayload: async (_session, args) => ({
566
- code: args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode),
567
- contentSha256: args.content === undefined ? null : sha256(args.content),
568
- note: "dry-run 已执行模板校验;真写会 compile→publish(插新 published 版本行并把规则切 CODING+enable)。",
569
- }),
604
+ dryRunPayload: async (session, args) => {
605
+ const code = args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode);
606
+ const rule = await ruleOf(session, args.schemaCode, args.businessRuleCode);
607
+ if (!rule)
608
+ throw new ToolRefusalError({ status: "refused", blocker: "programming-rule-absent", message: `业务规则 ${args.schemaCode}/${args.businessRuleCode} 不存在:publish 必须携带 businessRule 整模型。` });
609
+ let content = args.content;
610
+ if (content === undefined) {
611
+ const read = await readSource(session, { schemaCode: args.schemaCode, businessRuleCode: args.businessRuleCode });
612
+ content = typeof read.model?.content === "string" ? read.model.content : undefined;
613
+ }
614
+ if (content === undefined || content.trim() === "")
615
+ throw new ToolRefusalError({ status: "refused", blocker: "programming-content-required", message: "没有可发布的源码:传 content,或先 programming.save 落库草稿。" });
616
+ assertTemplateShape(content, code);
617
+ const compiled = await compileSource(session, code, content);
618
+ if (!compiled.success)
619
+ throw new ToolRefusalError({ status: "refused", blocker: "programming-compile-failed", message: "编译失败,拒绝 dry-run(真写也会拒绝发布)。", errors: compiled.presented.errors ?? compiled.errors, userErrors: compiled.presented.userErrors, platformErrors: compiled.presented.platformErrors });
620
+ return { code, contentSha256: sha256(content), contentBytes: Buffer.byteLength(content, "utf8"), note: "dry-run 与真写执行相同的规则存在、模板形状、编译 preflight;不会写入或切 CODING。" };
621
+ },
622
+ rollback: publishRollback(),
570
623
  });
571
624
  }
572
625
  export { PROGRAMMING_WIRE };