@stalfh233/omc-cli 0.4.41 → 0.4.43

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.
@@ -253,7 +253,11 @@ function lintWorkflowTemplate(template, tool) {
253
253
  for (const activity of activities) {
254
254
  const record = activity;
255
255
  const type = record != null && typeof record === "object" ? (record.type ?? record.activityType) : undefined;
256
- if (type === undefined || type === null || String(type).trim() === "")
256
+ // Permission/rename rows target an existing draft node and inherit its
257
+ // type at merge time (see validateCallerActivities) — only a bare new
258
+ // node must carry its own type here.
259
+ const targetsExistingNode = record != null && typeof record === "object" && (record.propertyPermissions !== undefined || record.subPropertyPermissions !== undefined || record.activityName !== undefined || record.name !== undefined || record.i18nMessage !== undefined || record.permittedAction !== undefined || record.buttonPermission !== undefined);
260
+ if ((type === undefined || type === null || String(type).trim() === "") && !targetsExistingNode)
257
261
  throw new ToolRefusalError({
258
262
  status: "refused",
259
263
  blocker: "workflow-activity-type-missing",
@@ -286,8 +290,7 @@ function mapCallerActivities(callerActivities, draftActivities) {
286
290
  return callerActivities.map((entry) => {
287
291
  const record = (entry ?? {});
288
292
  const code = activityCodeOf(record);
289
- const rawType = String(record.activityType ?? record.type ?? "");
290
- const type = rawType === "APPROVAL" ? "PARTICIPANT" : rawType;
293
+ const type = activityTypeOf(record);
291
294
  const base = draftByCode.get(code) ??
292
295
  (type === "START" ? draftByType("START") : type === "END" ? draftByType("END") : type === "PARTICIPANT" ? draftByType("PARTICIPANT") : undefined) ??
293
296
  {};
@@ -300,13 +303,23 @@ function mapCallerActivities(callerActivities, draftActivities) {
300
303
  ...Object.fromEntries(Object.entries(record).filter(([key]) => !ACTIVITY_INPUT_ALIAS_KEYS.includes(key))),
301
304
  ...(record.participant !== undefined ? { participant: normalizeParticipantForWire(record.participant) } : {}),
302
305
  };
303
- return {
306
+ const mapped = {
304
307
  ...base,
305
308
  activityCode: code,
306
309
  ...(type ? { activityType: type } : {}),
307
310
  ...(record.name !== undefined ? { activityName: record.name } : {}),
308
311
  ...wireRecord,
309
312
  };
313
+ // Replace path: a caller-only code with no type has no draft row to
314
+ // inherit from — refuse before the wire (same rule the merge path
315
+ // enforces for its appended nodes).
316
+ if (String(mapped.activityType ?? "") === "")
317
+ throw new ToolRefusalError({
318
+ status: "refused",
319
+ blocker: "workflow-activity-type-missing",
320
+ message: `workflow: 新节点 ${code} 缺 type/activityType(省略类型仅当 activityCode 命中草稿既有节点——权限/改名编辑行)`,
321
+ });
322
+ return mapped;
310
323
  });
311
324
  }
312
325
  function validateCallerActivities(template, tool) {
@@ -323,9 +336,16 @@ function validateCallerActivities(template, tool) {
323
336
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-code-invalid", message: `${tool}: activities[${index}] requires a non-empty activityCode/id` });
324
337
  if (codes.has(code))
325
338
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-code-duplicate", message: `${tool}: duplicate activityCode ${code}` });
326
- if (!rawType)
327
- throw new ToolRefusalError({ status: "refused", blocker: "workflow-activity-type-missing", message: `${tool}: activities[${index}] requires type/activityType` });
328
- if (!WORKFLOW_ACTIVITY_TYPES.has(type))
339
+ if (!rawType) {
340
+ // A permission-edit row `{activityCode, propertyPermissions}` targets an
341
+ // EXISTING draft node and inherits its type at merge time — demanding a
342
+ // type here forced agents to re-send identity fields, and any spelling
343
+ // drift then minted `_2` codes. Bare new nodes still need a type.
344
+ const targetsExistingNode = activity?.propertyPermissions !== undefined || activity?.subPropertyPermissions !== undefined || activity?.activityName !== undefined || activity?.name !== undefined || activity?.i18nMessage !== undefined || activity?.permittedAction !== undefined || activity?.buttonPermission !== undefined;
345
+ if (!targetsExistingNode)
346
+ throw new ToolRefusalError({ status: "refused", blocker: "workflow-activity-type-missing", message: `${tool}: activities[${index}] requires type/activityType(权限编辑行带 propertyPermissions 时可省——类型继承自草稿同名节点)` });
347
+ }
348
+ if (rawType !== "" && !WORKFLOW_ACTIVITY_TYPES.has(type))
329
349
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-type-invalid", message: `${tool}: activities[${index}] has an illegal ActivityType` });
330
350
  codes.add(code);
331
351
  }
@@ -688,9 +708,9 @@ function completeWorkflowChain(rules, activities) {
688
708
  const byEdge = new Map(rules.map((rule) => [`${rule.preActivityCode}->${rule.postActivityCode}`, rule]));
689
709
  return codes.slice(0, -1).map((code, index) => byEdge.get(`${code}->${codes[index + 1]}`) ?? { preActivityCode: code, postActivityCode: codes[index + 1] });
690
710
  }
691
- /** Activity type resolution shared by the merge + code reconcile (APPROVAL is a legacy alias). */
711
+ /** Activity type resolution shared by the merge + code reconcile (APPROVAL is a legacy alias; case-insensitive — "Participant"/"approval" are the same node type as the draft's "PARTICIPANT" and must not mint a fresh code). */
692
712
  function activityTypeOf(record) {
693
- const rawType = String(record.activityType ?? record.type ?? "");
713
+ const rawType = String(record.activityType ?? record.type ?? "").trim().toUpperCase();
694
714
  return rawType === "APPROVAL" ? "PARTICIPANT" : rawType;
695
715
  }
696
716
  /** Display name the caller asked for, in any accepted spelling. */
@@ -747,9 +767,16 @@ function reconcileCallerActivityCodes(callerActivities, draftActivities) {
747
767
  const draft = draftByCode.get(original);
748
768
  let code = original;
749
769
  if (draft !== undefined) {
750
- const draftType = String(draft.activityType ?? "");
770
+ const draftType = String(draft.activityType ?? "").trim().toUpperCase() === "APPROVAL" ? "PARTICIPANT" : String(draft.activityType ?? "").trim().toUpperCase();
751
771
  const draftName = activityNameOf(draft);
752
- const changed = draftType !== type || (name !== undefined && draftName !== undefined && name !== draftName);
772
+ // Only a field the caller ACTUALLY supplied can constitute a change: a
773
+ // delta edit that re-sends `{activityCode, propertyPermissions}` (no
774
+ // type/name) inherits the draft's identity — minting `_2` codes for it
775
+ // churned the code on every edit (Activity3 → Activity3_2 →
776
+ // Activity3_2_2). An omitted type is "keep the stored one", not "changed
777
+ // to empty".
778
+ const callerHasType = record.activityType !== undefined || record.type !== undefined;
779
+ const changed = (callerHasType && draftType !== type) || (name !== undefined && draftName !== undefined && name !== draftName);
753
780
  if (changed) {
754
781
  let suffix = 2;
755
782
  while (used.has(`${original}_${suffix}`))
@@ -836,6 +863,152 @@ function deepMergeActivityRow(base, caller) {
836
863
  merged.propertyPermissions = mergePermissionsByCode(base.propertyPermissions, caller.propertyPermissions);
837
864
  return merged;
838
865
  }
866
+ /**
867
+ * Live 2026-10-14 (designer-template parity): flows created/updated through
868
+ * the API miss three defaults the designer writes on its first save, and the
869
+ * node config visibly drifts in the UI:
870
+ *
871
+ * 1. the FILL node ({Originator}) carries an EMPTY buttonPermission
872
+ * .childTableBtns — the designer pre-checks ADD/DELETE/IMPORT/EXPORT for
873
+ * EVERY child-table container (BATCH_UPDATE stays disabled);
874
+ * 2. the fill node keeps the auto-draft showReject:true — the designer
875
+ * default is false (the originator cannot reject their own submission);
876
+ * 3. approval nodes keep rejectToStart:false — the designer default is true
877
+ * (reject goes back to the originator node).
878
+ *
879
+ * Backfill applies ONLY to keys the caller did not explicitly send for that
880
+ * node in this update, so an explicit permittedAction/buttonPermission always
881
+ * wins.
882
+ */
883
+ /**
884
+ * Child-table fields of a schema, draft face first (the field lane's own
885
+ * draft-else-published semantics: after field.publish the draft face may be
886
+ * empty while the published face carries the row).
887
+ */
888
+ async function childTableFieldsOf(session, schemaCode) {
889
+ const read = async (isPublish) => {
890
+ try {
891
+ return wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish } })).data);
892
+ }
893
+ catch {
894
+ return [];
895
+ }
896
+ };
897
+ const rows = (await read("false")).length > 0 ? await read("false") : await read("true");
898
+ return rows
899
+ .map((row) => ({ code: String(row.code ?? row.propertyCode ?? ""), name: String(row.name ?? row.code ?? ""), isChild: Number(row.type ?? row.propertyType ?? 0) === 8 || String(row.type ?? row.propertyType ?? "").toUpperCase() === "CHILD_TABLE" || row.subSchema !== undefined }))
900
+ .filter((row) => row.isChild && row.code !== "");
901
+ }
902
+ const CHILD_TABLE_LIST_BUTTONS = [
903
+ { code: "ADD", name: "新增", enabled: true },
904
+ { code: "DELETE", name: "删除", enabled: true },
905
+ { code: "IMPORT", name: "导入", enabled: true },
906
+ { code: "EXPORT", name: "导出", enabled: true },
907
+ { code: "BATCH_UPDATE", name: "批量编辑", enabled: false },
908
+ ];
909
+ function isOriginatorFillNode(activity) {
910
+ return /\{originator\}/i.test(String(activity.participant ?? ""));
911
+ }
912
+ async function backfillDesignerDefaults(session, schemaCode, body, callerTemplate) {
913
+ const activities = Array.isArray(body.activities) ? body.activities : [];
914
+ const participants = activities.filter((activity) => String(activity.activityType ?? "") === "PARTICIPANT");
915
+ if (participants.length === 0)
916
+ return;
917
+ // Caller rows by FINAL activityCode: keys the caller explicitly sent for a
918
+ // node must not be overwritten by the backfill.
919
+ const callerRows = new Map();
920
+ for (const row of (callerActivities(callerTemplate) ?? [])) {
921
+ const code = activityCodeOf(row);
922
+ if (code)
923
+ callerRows.set(code, row);
924
+ }
925
+ const childTables = await childTableFieldsOf(session, schemaCode);
926
+ for (const activity of participants) {
927
+ const code = String(activity.activityCode ?? "");
928
+ const callerRow = callerRows.get(code);
929
+ const fill = isOriginatorFillNode(activity);
930
+ // 2/3: reject-action designer defaults (fill: cannot reject self;
931
+ // approval: reject returns to the originator node).
932
+ // The auto-draft PRESENCE of these keys is not designer intent — it ships
933
+ // showReject:true / rejectToStart:false everywhere. Overwrite toward the
934
+ // designer default unless THIS update explicitly sent permittedAction for
935
+ // the node.
936
+ const action = (activity.permittedAction ?? {});
937
+ if (callerRow?.permittedAction === undefined) {
938
+ if (fill)
939
+ activity.permittedAction = { ...action, showReject: false, rejectToStart: false };
940
+ else
941
+ activity.permittedAction = { ...action, rejectToStart: true };
942
+ }
943
+ // 1: child-table list buttons on the fill node only (designer parity:
944
+ // approval nodes keep childTableBtns empty).
945
+ if (fill && !(callerRow?.buttonPermission !== undefined) && childTables.length > 0) {
946
+ const buttonPermission = (activity.buttonPermission ?? {});
947
+ const existing = Array.isArray(buttonPermission.childTableBtns) ? buttonPermission.childTableBtns : [];
948
+ if (existing.length === 0) {
949
+ activity.buttonPermission = {
950
+ ...buttonPermission,
951
+ containerCode: buttonPermission.containerCode ?? schemaCode,
952
+ customBtns: buttonPermission.customBtns ?? [],
953
+ childTableBtns: childTables.map((table) => ({
954
+ containerCode: table.code,
955
+ containerName: table.name,
956
+ customBtns: CHILD_TABLE_LIST_BUTTONS.map((button) => ({
957
+ code: button.code,
958
+ name: button.name,
959
+ name_i18n: { zh: button.name, zh_TW: button.name, en: button.name },
960
+ disabled: !button.enabled,
961
+ type: "list",
962
+ enabled: button.enabled,
963
+ })),
964
+ })),
965
+ };
966
+ }
967
+ }
968
+ }
969
+ }
970
+ /**
971
+ * Rename pairs surfaced in the readback (live r51 2026-10-14): a rename mints
972
+ * `<original>_<n>` and DROPS the original code — re-sending the OLD code then
973
+ * reads as a brand-new node and APPENDS it, silently growing the flow. The
974
+ * readback names the pairs so the caller switches to the new code.
975
+ */
976
+ function renamedActivityPairs(template, data) {
977
+ const callerCodes = new Set((callerActivities(template) ?? []).map((row) => activityCodeOf(row)).filter((code) => code !== ""));
978
+ const bodyCodes = new Set((Array.isArray(data.activities) ? data.activities : []).map((row) => String(row.activityCode ?? "")));
979
+ const pairs = [];
980
+ for (const code of bodyCodes) {
981
+ const match = /^(.+)_\d+$/.exec(code);
982
+ if (match && callerCodes.has(match[1]) && !bodyCodes.has(match[1]))
983
+ pairs.push({ from: match[1], to: code });
984
+ }
985
+ return pairs;
986
+ }
987
+ /**
988
+ * Live 2026-10-14: a workflow node's propertyPermissions ACCEPTS
989
+ * `required:true` on a CHILD_TABLE (子表) property at the wire level, but the
990
+ * designer refuses that shape — the node's field config cannot render/必填 a
991
+ * sub-table control. Fail closed BEFORE the write (we never store a shape the
992
+ * designer cannot represent); only CALLER-supplied rows are inspected, so an
993
+ * inherited draft row never blocks an unrelated edit. The field-list read is
994
+ * lazy: it fires only when the caller actually sets required somewhere.
995
+ */
996
+ async function refuseRequiredOnChildTableFields(session, schemaCode, template, tool) {
997
+ const rows = (callerActivities(template) ?? []).flatMap((activity) => (Array.isArray(activity.propertyPermissions) ? activity.propertyPermissions : []));
998
+ const requiredCodes = [...new Set(rows.filter((row) => row?.required === true).map((row) => String(row.propertyCode ?? "")))].filter((code) => code !== "");
999
+ if (requiredCodes.length === 0)
1000
+ return;
1001
+ const childTables = await childTableFieldsOf(session, schemaCode);
1002
+ const hits = requiredCodes.filter((code) => childTables.some((table) => table.code === code));
1003
+ if (hits.length > 0)
1004
+ throw new ToolRefusalError({
1005
+ status: "refused",
1006
+ blocker: "workflow-child-table-not-required",
1007
+ schemaCode,
1008
+ propertyCodes: hits,
1009
+ message: `${tool}: 子表字段不能配置为必填(propertyPermissions.required)——接口形状接受但设计器节点字段配置不支持(子表控件无必填语义),存入后节点配置无法正常渲染。命中的子表字段:${hits.join("、")}。子表行只配置 visible/editable(summary 工具自动补);字段级必填请配到子表内部字段所在节点或改数据规则校验`,
1010
+ });
1011
+ }
839
1012
  /**
840
1013
  * Incremental (`merge:true`) workflow edit: the caller sends ONLY the activities/
841
1014
  * routes it adds or changes; the draft is the base. Draft activities survive
@@ -896,6 +1069,18 @@ function mergeCallerDelta(template, draft) {
896
1069
  return { ...activity, ...override };
897
1070
  });
898
1071
  const newActivities = [...callerByOriginal.values()];
1072
+ // A caller-only code that arrived WITHOUT a type has no draft row to inherit
1073
+ // from — refuse it here (the lint allows type-less permission-shape rows
1074
+ // precisely because they are expected to match an existing node).
1075
+ for (const activity of newActivities) {
1076
+ const type = activityTypeOf(activity);
1077
+ if (type === "")
1078
+ throw new ToolRefusalError({
1079
+ status: "refused",
1080
+ blocker: "workflow-activity-type-missing",
1081
+ message: `workflow.update: 新节点 ${String(activity.activityCode ?? "")} 缺 type/activityType(权限编辑行可省类型仅当 activityCode 命中草稿既有节点)`,
1082
+ });
1083
+ }
899
1084
  // Insert added nodes BEFORE the terminal END, so the auto-chain reads
900
1085
  // START → …existing… → new → END (appending after END produced a chain that
901
1086
  // ended on the new node instead of END).
@@ -1021,6 +1206,53 @@ function normalizeWorkflowTemplate(template, draft) {
1021
1206
  * 50000 (live incident). Caller keys override; everything else echoes the
1022
1207
  * draft verbatim (the same merge pattern workflow.bindRule uses).
1023
1208
  */
1209
+ /**
1210
+ * Rollback specs (git-revert semantics): before-state = the full draft row
1211
+ * captured via get_draft; the restore replays workflow.update with the
1212
+ * before template as a full-row replace (mergedTemplateBody echoes the draft
1213
+ * verbatim when the caller template IS the draft row).
1214
+ */
1215
+ function workflowRollbackSpec(toolName) {
1216
+ return {
1217
+ target: (args) => `workflow:${String(args.schemaCode ?? "")}/${String(args.code ?? args.workflowCode ?? "")}`,
1218
+ read: async (session, args) => {
1219
+ const code = String(args.code ?? args.workflowCode ?? "") || (await resolveWorkflowArg(session, { workflowCode: undefined, schemaCode: args.schemaCode }) ?? undefined);
1220
+ if (!code)
1221
+ return null;
1222
+ return getWorkflowDraftOrNull(session, code);
1223
+ },
1224
+ inverse: ({ before, args, readback }) => {
1225
+ const schemaCode = String(args.schemaCode ?? "");
1226
+ const code = String(args.code ?? args.workflowCode ?? "");
1227
+ if (toolName === "workflow.create") {
1228
+ if (readback.alreadyPresent === true)
1229
+ return { kind: "calls", calls: [], notes: ["create 时已存在(幂等跳过),未写入,无需回滚"] };
1230
+ return { kind: "calls", calls: [{ tool: "workflow.delete", args: { schemaCode, code } }] };
1231
+ }
1232
+ if (toolName === "workflow.delete") {
1233
+ if (before == null)
1234
+ return { kind: "calls", calls: [], notes: ["delete 前目标已缺席(幂等),无需回滚"] };
1235
+ const row = before;
1236
+ return {
1237
+ kind: "calls",
1238
+ calls: [
1239
+ { tool: "workflow.create", args: { schemaCode, code, name: String(row.workflowName ?? row.name ?? code) } },
1240
+ { tool: "workflow.update", args: { schemaCode, code, template: row, merge: false } },
1241
+ ],
1242
+ };
1243
+ }
1244
+ if (toolName === "workflow.publish")
1245
+ return {
1246
+ kind: "irreversible",
1247
+ reason: "平台无 unpublish:发布是生命周期推进(workflowVersion 递增),不可参数重放撤销。内容回退路径:对该流程前一 workflow.update/bindRule 的 before 执行 rollback plan/apply 恢复草稿内容后,重新 workflow.publish",
1248
+ };
1249
+ // update / bindRule:整模板替换回 before 草稿
1250
+ if (before == null)
1251
+ return { kind: "irreversible", reason: "before 快照为空(流程不存在),无内容可恢复" };
1252
+ return { kind: "calls", calls: [{ tool: "workflow.update", args: { schemaCode, code, template: before, merge: false } }] };
1253
+ },
1254
+ };
1255
+ }
1024
1256
  async function mergedTemplateBody(session, schemaCode, code, template, merge = false) {
1025
1257
  const draft = await getWorkflowDraftOrNull(session, code);
1026
1258
  if (draft == null)
@@ -1391,13 +1623,14 @@ export function registerWorkflowLane(tools) {
1391
1623
  performed: { errcode: performed.errcode },
1392
1624
  readback: { workflowCode: args.code, headerPresent: true, draftPresent: draft != null, templateId: draft?.id ?? null },
1393
1625
  };
1394
- });
1626
+ }, undefined, undefined, workflowRollbackSpec("workflow.create"));
1395
1627
  writeTool(tools, "workflow.update", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.update"].scope, WORKFLOW_TOOLS["workflow.update"].description, { schemaCode: z.string().min(1), code: z.string().min(1), template: workflowTemplateArg, merge: z.boolean().optional().describe("增量:只发改动/新增的活动与路由,工具按 activityCode 合并到草稿(草稿为基;新增节点自动接链),不必先读回整模板再重发。默认自动:模板缺 START 或 END 时按增量 merge(部分模板),给了完整 START..END 才整模板替换;显式 true 强制增量、false 强制替换") }, async (session, args) => {
1396
1628
  const template = args.template ?? {};
1397
1629
  validateCallerActivities(template, "workflow.update");
1398
1630
  validateWorkflowRouteConditions(template, "workflow.update");
1399
1631
  assertTemplateNotMinimal(template, "workflow.update");
1400
1632
  lintWorkflowTemplate(template, "workflow.update");
1633
+ await refuseRequiredOnChildTableFields(session, args.schemaCode, template, "workflow.update");
1401
1634
  // Ticket 112: merge onto the full draft row — a verbatim minimal post
1402
1635
  // leaves converter holes and answers errcode 50000 on the platform.
1403
1636
  // Delta semantics: explicit `true` forces merge, explicit `false` forces
@@ -1405,6 +1638,7 @@ export function registerWorkflowLane(tools) {
1405
1638
  // START/END) as a delta — see activitiesLackBoundaries.
1406
1639
  const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
1407
1640
  const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
1641
+ await backfillDesignerDefaults(session, args.schemaCode, body, template);
1408
1642
  validateWorkflowRender(body, "workflow.update");
1409
1643
  // Live: workflow/update does NOT run the template validator — it stores a
1410
1644
  // refused participant silently and only /publish answers the
@@ -1416,8 +1650,13 @@ export function registerWorkflowLane(tools) {
1416
1650
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-readback-invalid", message: "workflow.update returned no draft" });
1417
1651
  validateWorkflowRender(data, "workflow.update readback");
1418
1652
  const participantWarnings = participantReferenceWarnings(data);
1419
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : 0, templateType: data.templateType ?? null, ...(participantWarnings.length ? { participantWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1420
- });
1653
+ const renamedActivities = renamedActivityPairs(template, data);
1654
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : 0, templateType: data.templateType ?? null, ...(participantWarnings.length ? { participantWarnings } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "改名会铸新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1655
+ }, (args) => {
1656
+ // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
1657
+ // governed pipeline — including the rollback before-capture read.
1658
+ assertTemplateNotMinimal(args.template ?? {}, "workflow.update");
1659
+ }, undefined, workflowRollbackSpec("workflow.update"));
1421
1660
  writeTool(tools, "workflow.publish", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.publish"].scope, WORKFLOW_TOOLS["workflow.publish"].description, { schemaCode: z.string().min(1), code: z.string().min(1), template: workflowTemplateArg.optional() }, async (session, args) => {
1422
1661
  // An absent template publishes the stored draft as-is — the platform's own
1423
1662
  // path. The schema is optional so the contract no longer contradicts it.
@@ -1425,10 +1664,12 @@ export function registerWorkflowLane(tools) {
1425
1664
  validateCallerActivities(template, "workflow.publish");
1426
1665
  validateWorkflowRouteConditions(template, "workflow.publish");
1427
1666
  assertTemplateNotMinimal(template, "workflow.publish");
1667
+ await refuseRequiredOnChildTableFields(session, args.schemaCode, template, "workflow.publish");
1428
1668
  lintWorkflowTemplate(template, "workflow.publish");
1429
1669
  // Ticket 112: same merge-before-write as update. A template without both
1430
1670
  // boundaries is a partial edit → delta merge (see activitiesLackBoundaries).
1431
1671
  const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
1672
+ await backfillDesignerDefaults(session, args.schemaCode, body, template);
1432
1673
  validateWorkflowRender(body, "workflow.publish");
1433
1674
  const performed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
1434
1675
  // A refused publish answers errcode 0 — the ValidateMessage[] is the truth.
@@ -1454,7 +1695,9 @@ export function registerWorkflowLane(tools) {
1454
1695
  validateWorkflowRender(data, "workflow.publish readback");
1455
1696
  const participantWarnings = participantReferenceWarnings(data);
1456
1697
  return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
1457
- });
1698
+ }, (args) => {
1699
+ assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");
1700
+ }, undefined, workflowRollbackSpec("workflow.publish"));
1458
1701
  writeTool(tools, "workflow.bindRule", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.bindRule"].scope, WORKFLOW_TOOLS["workflow.bindRule"].description, {
1459
1702
  schemaCode: z.string().min(1),
1460
1703
  workflowCode: z.string().min(1),
@@ -1577,7 +1820,7 @@ export function registerWorkflowLane(tools) {
1577
1820
  if (codes.length === 0 || new Set(codes.map(String)).size !== codes.length || codes.some((code) => typeof code !== "string" || code.length === 0))
1578
1821
  throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "ruleCodes 必须是非空、去重的规则 code 数组" });
1579
1822
  }
1580
- });
1823
+ }, undefined, workflowRollbackSpec("workflow.bindRule"));
1581
1824
  writeTool(tools, "workflow.delete", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.delete"].scope, WORKFLOW_TOOLS["workflow.delete"].description, { schemaCode: z.string().min(1), code: z.string().min(1) }, async (session, args) => {
1582
1825
  const performed = await wireCall(session, {
1583
1826
  path: ENDPOINTS.workflowDelete,
@@ -1586,7 +1829,7 @@ export function registerWorkflowLane(tools) {
1586
1829
  });
1587
1830
  const header = await wireCall(session, { path: ENDPOINTS.workflowGetHeader, query: { workflowCode: args.code } }).catch(() => ({ data: null }));
1588
1831
  return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, absent: header.data === null || header.data === undefined } };
1589
- });
1832
+ }, undefined, undefined, workflowRollbackSpec("workflow.delete"));
1590
1833
  // ---- high-level greenfield orchestration (round 10 ergonomics) --------
1591
1834
  // The survey's write-face gap: a first workflow took create → update →
1592
1835
  // publish (+ bindRule for rules), 3–4 separate CLI calls. configure_workflow
@@ -245,12 +245,49 @@ export function composeCustomScript(existing, incoming, mode) {
245
245
  // Merge into a lifecycle body: seed one when the existing script has none.
246
246
  let base = hasLifecycleWrapper(trimmedExisting) ? existing : wrapInLifecycle(existing);
247
247
  for (const block of splitRegistrations(incoming)) {
248
- if (block.event !== null)
248
+ if (block.event !== null) {
249
249
  base = removeRegistration(base, block.event);
250
+ base = insertIntoLifecycleBody(base, block.text);
251
+ continue;
252
+ }
253
+ // Idempotent re-send (live 2026-10-14 中冶): re-composing the same file
254
+ // used to stack a SECOND copy of the header comment + var preamble on
255
+ // every pass. A non-registration block that already lives in the script
256
+ // verbatim is a no-op, not another append.
257
+ if (base.includes(block.text))
258
+ continue;
259
+ // A block that OPENS with the same header comment as the existing script
260
+ // but differs elsewhere is a whole-file REVISION: appending would
261
+ // duplicate the preamble AND leave the old body active. Fail closed and
262
+ // route to the replace path instead of guessing a textual merge.
263
+ const header = leadingCommentOf(block.text);
264
+ if (header !== null && header.length >= 16 && base.includes(header))
265
+ throw new ToolRefusalError({
266
+ status: "refused",
267
+ blocker: "onlinejs-revision-replace-required",
268
+ message: "传入源与既有脚本同头注释(同一份脚本文件的修订版)——merge 会把注释/变量块重复追加且旧逻辑仍生效。整文件修订请:onlinejs.get 取回现脚本 → 修改 → onlinejs.compose replace:true 传完整 (function(form){…}) 脚本;只追加新逻辑则去掉与既有脚本重复的头注释/变量块后再发",
269
+ });
250
270
  base = insertIntoLifecycleBody(base, block.text);
251
271
  }
252
272
  return base;
253
273
  }
274
+ /** The first /** … *\/ or leading // comment run of `text`, verbatim; null when the text does not open with one. */
275
+ function leadingCommentOf(text) {
276
+ const trimmed = text.trimStart();
277
+ if (trimmed.startsWith("/*")) {
278
+ const end = trimmed.indexOf("*/");
279
+ if (end > 0)
280
+ return trimmed.slice(0, end + 2);
281
+ }
282
+ const lines = [];
283
+ for (const line of trimmed.split("\n")) {
284
+ if (line.trimStart().startsWith("//"))
285
+ lines.push(line);
286
+ else
287
+ break;
288
+ }
289
+ return lines.length > 0 ? lines.join("\n") : null;
290
+ }
254
291
  export function assertPlatformOnlineJsComposition() {
255
292
  const source = "form.on('onLoad', function(){});";
256
293
  const composed = composeCustomScript("", source, "merge");
@@ -8,12 +8,12 @@ const onlinejsHighlightScript = Object.freeze({
8
8
  environment,
9
9
  schemaCode: model,
10
10
  sheetCode: "<默认表单 code == 模型 code>",
11
- mode: "replace",
11
+ replace: true,
12
12
  source: "(function(form){ var amt = form.getControl('amountCode'); var party = form.getControl('targetCode'); function refresh(v){ party.options.style = Number(v) > 100000 ? 'color: red;' : ''; } form.on('onRendered', function(){ refresh(amt.getValue()); amt.valueChange.subscribe(function(change){ refresh(change.value); }); }); })",
13
13
  },
14
14
  preconditions: [
15
15
  "控件编码先 model.inspect/field.list 确认(示例 amountCode/targetCode 换成真实字段 code)",
16
- "完整 `(function(form){…})` 生命周期外壳必须 mode:'replace'(merge 模式收外壳会被拒并指路 replace——r38 实证)",
16
+ "完整 `(function(form){…})` 生命周期外壳必须配 `replace:true`(默认 INCREMENTAL merge 收外壳会被拒并指路 replace——r38 实证)",
17
17
  ],
18
18
  conditionalRequirements: [
19
19
  "事件模型:`form.on('onRendered', …)` 初始化 + `<控件>.valueChange.subscribe(function(change){ change.value })` 监听;改样式用 `control.options.style`",
@@ -23,7 +23,7 @@ const onlinejsHighlightScript = Object.freeze({
23
23
  priorToolReferences: [{ tool: "form.get", args: { environment, schemaCode: model }, purpose: "确认表单已发布与控件编码" }],
24
24
  dryRun: { supported: true, command: `omc call onlinejs.compose --env ${environment} --dry-run --args-file compose.json`, notes: "dry-run 校验外壳/合并模式,不写平台" },
25
25
  expectedReadback: { readback: { deliveryState: "DELIVERED", published: true, scriptElementPresent: true, htmlRenderCompat: { draft: { ok: true }, published: { ok: true } } } },
26
- refusalRecovery: [{ code: "merge-mode-requires-shell", action: "完整外壳改 mode:'replace'(六元素模板保留,仅整体替换 customScript)" }],
26
+ refusalRecovery: [{ code: "onlinejs-source-invalid", action: "完整外壳改 `replace:true`(六元素模板保留,仅整体替换 customScript)" }],
27
27
  requiresEnvironmentDerivedIds: false,
28
28
  });
29
29
  export const ONLINEJS_METADATA = Object.freeze({
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Field-level deep diff for the plan surface. Returns at most `limit`
3
+ * changed paths plus the total count; arrays are compared by index
4
+ * (length change is reported on the array path itself).
5
+ */
6
+ export function deepDiff(before, current, path = "", limit = 40, acc = []) {
7
+ const walk = (b, c, p) => {
8
+ if (acc.length >= limit)
9
+ return;
10
+ if (b === null && c === null)
11
+ return;
12
+ const bothObject = b !== null && c !== null && typeof b === "object" && typeof c === "object";
13
+ if (!bothObject) {
14
+ if (b !== c && !(Number.isNaN(b) && Number.isNaN(c))) {
15
+ if (typeof b === "string" || typeof c === "string") {
16
+ const bp = tryParseJson(b);
17
+ const cp = tryParseJson(c);
18
+ if (bp.ok && cp.ok)
19
+ return walk(bp.value, cp.value, p);
20
+ }
21
+ acc.push({ path: p, before: b, current: c });
22
+ }
23
+ return;
24
+ }
25
+ const bArr = Array.isArray(b);
26
+ const cArr = Array.isArray(c);
27
+ if (bArr || cArr) {
28
+ if (!bArr || !cArr) {
29
+ acc.push({ path: p, before: b, current: c });
30
+ return;
31
+ }
32
+ const bA = b;
33
+ const cA = c;
34
+ if (bA.length !== cA.length)
35
+ acc.push({ path: p, before: `<array×${bA.length}>`, current: `<array×${cA.length}>` });
36
+ const shared = Math.min(bA.length, cA.length);
37
+ for (let i = 0; i < shared && acc.length < limit; i += 1)
38
+ walk(bA[i], cA[i], `${p}[${i}]`);
39
+ return;
40
+ }
41
+ const bRec = b;
42
+ const cRec = c;
43
+ for (const key of new Set([...Object.keys(bRec), ...Object.keys(cRec)])) {
44
+ if (acc.length >= limit)
45
+ return;
46
+ const inB = key in bRec;
47
+ const inC = key in cRec;
48
+ if (!inB || !inC) {
49
+ acc.push({
50
+ path: joinPath(p, key),
51
+ before: inB ? bRec[key] : "<absent>",
52
+ current: inC ? cRec[key] : "<absent>",
53
+ });
54
+ continue;
55
+ }
56
+ walk(bRec[key], cRec[key], joinPath(p, key));
57
+ }
58
+ };
59
+ walk(before, current, path);
60
+ return { changes: acc, total: acc.length };
61
+ }
62
+ /**
63
+ * Drop null/undefined values from inverse-call args, recursively (live r55
64
+ * 2026-10-14): the wire rows carry explicit nulls (`relativeCode:null`) and
65
+ * `!== undefined` guards let them through — the target tool's input schema then
66
+ * rejects the replay as input-invalid (exit 10) and the restore stops. A null
67
+ * in the before row means "no value", and for every replayable config tool an
68
+ * ABSENT key is the same meaning with a schema the tool accepts.
69
+ */
70
+ export function pruneNullish(value) {
71
+ if (Array.isArray(value))
72
+ return value.map(pruneNullish);
73
+ if (value !== null && typeof value === "object") {
74
+ const out = {};
75
+ for (const [key, item] of Object.entries(value)) {
76
+ if (item === null || item === undefined)
77
+ continue;
78
+ out[key] = pruneNullish(item);
79
+ }
80
+ return out;
81
+ }
82
+ return value;
83
+ }
84
+ /**
85
+ * Canonical projection for restore verification: a replayed restore will
86
+ * NEVER be byte-equal to the before snapshot — the platform stamps
87
+ * modifiedTime/workflowVersion, geometry and selection state live on
88
+ * activities/rules, and a delete→recreate cycle mints fresh ids. Verification
89
+ * therefore compares the durable CONFIG content with these volatile keys
90
+ * dropped at every depth.
91
+ */
92
+ const VOLATILE_VERIFY_KEYS = new Set([
93
+ "id",
94
+ "modifiedTime",
95
+ "createdTime",
96
+ "createdBy",
97
+ "modifiedBy",
98
+ "deleted",
99
+ "workflowVersion",
100
+ "published",
101
+ "isSelected",
102
+ "icon",
103
+ "x",
104
+ "y",
105
+ "width",
106
+ "height",
107
+ "points",
108
+ "textPosition",
109
+ "fixedPoint",
110
+ "shortCode",
111
+ ]);
112
+ export function canonicalForVerify(value) {
113
+ if (Array.isArray(value))
114
+ return value.map(canonicalForVerify);
115
+ if (value !== null && typeof value === "object") {
116
+ const record = value;
117
+ const out = {};
118
+ for (const key of Object.keys(record).sort()) {
119
+ if (VOLATILE_VERIFY_KEYS.has(key))
120
+ continue;
121
+ out[key] = canonicalForVerify(record[key]);
122
+ }
123
+ return out;
124
+ }
125
+ return value;
126
+ }
127
+ function joinPath(base, key) {
128
+ return base === "" ? key : `${base}.${key}`;
129
+ }
130
+ function tryParseJson(value) {
131
+ if (typeof value !== "string")
132
+ return { ok: false };
133
+ const trimmed = value.trim();
134
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
135
+ return { ok: false };
136
+ try {
137
+ return { ok: true, value: JSON.parse(trimmed) };
138
+ }
139
+ catch {
140
+ return { ok: false };
141
+ }
142
+ }
143
+ //# sourceMappingURL=rollback-spec.js.map