@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.
@@ -14,6 +14,7 @@ import { getReplayToken, listInstanceActivities, searchWorkItems, wireString } f
14
14
  import { PLATFORM_ADMIN_START_BYPASS, resolvePermissionActor } from "../identity.js";
15
15
  import { ENGINE_OWNED_KEYS, RUNTIME_DATA_LANE_CONTRACT, RUNTIME_DATA_WIRE, runtimeFormDataArg, } from "./runtime-data-contract.js";
16
16
  import { SYSTEM_FIELD_CODES } from "../field-families.js";
17
+ import { applicationResolutionOf, resolveApplicationScope } from "../support.js";
17
18
  /** r29b F8: keep ids/itemsPreview through governedRead's compact diet. */
18
19
  const DATA_LIST_PREVIEW_LIMIT = 20;
19
20
  function dataListItemPreview(items) {
@@ -55,6 +56,7 @@ function dataListCompactProjection(result) {
55
56
  if (value.length > 0)
56
57
  compact.listArtifactHint =
57
58
  "items 已外置(truncatedKeys 含 items):完整行见 artifact(detailArtifactId / fetch)。同信封三分法:ids=本页全部 id;itemsPreview=ids 前 3 个的引用(仅 {id},不是第二份行);items=[] 表示正文在 artifact。";
59
+ compact.itemsExternalized = true;
58
60
  continue;
59
61
  }
60
62
  // 这两个键由本函数按 items 现算,handler 不再重复内联。
@@ -370,6 +372,106 @@ function readbackWarningFor(emptyFields, toolName) {
370
372
  return undefined;
371
373
  return `${toolName} 已 DELIVERED,但即时读回里这些本次提交的字段为空/缺省:${emptyFields.join("、")}。平台 form/load 在写事务后可能先回系统字段、业务字段滞后(真机 1.1.25,r14 见过只回 modifiedTime 的一笔)。**先 omc call data.load 核对,不要重写**;若 data.load 仍为空,才按投影/字段 code 排查。`;
372
374
  }
375
+ function parseOptions(value) {
376
+ if (typeof value === "string") {
377
+ try {
378
+ const parsed = JSON.parse(value);
379
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
380
+ }
381
+ catch {
382
+ return {};
383
+ }
384
+ }
385
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
386
+ }
387
+ function choiceCodesFromFieldOptions(field, options) {
388
+ const asCodes = (value) => {
389
+ if (!Array.isArray(value))
390
+ return [];
391
+ return value
392
+ .map((entry) => {
393
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
394
+ const row = entry;
395
+ return String(row.code ?? row.value ?? row.key ?? "");
396
+ }
397
+ return String(entry ?? "");
398
+ })
399
+ .filter((code) => code.length > 0);
400
+ };
401
+ const direct = asCodes(field.choices);
402
+ if (direct.length > 0)
403
+ return direct;
404
+ for (const candidate of [options.choices, options.items]) {
405
+ const codes = asCodes(candidate);
406
+ if (codes.length > 0)
407
+ return codes;
408
+ }
409
+ const set = options.optionsSet;
410
+ if (!set || typeof set !== "object" || Array.isArray(set))
411
+ return [];
412
+ const optionSet = set;
413
+ for (const candidate of [optionSet.custom, optionSet.choices, optionSet.items, optionSet.useDictionariesData]) {
414
+ const codes = asCodes(candidate);
415
+ if (codes.length > 0)
416
+ return codes;
417
+ }
418
+ const dictionary = optionSet.dictionary;
419
+ if (dictionary && typeof dictionary === "object" && !Array.isArray(dictionary)) {
420
+ const codes = asCodes(dictionary.useDictionariesData);
421
+ if (codes.length > 0)
422
+ return codes;
423
+ }
424
+ return [];
425
+ }
426
+ /**
427
+ * Runtime/form/save deliberately remains permissive: the platform stores the
428
+ * row even when the portal would reject it. These are advisory findings only.
429
+ * In particular, never manufacture a warning for a field which is absent from
430
+ * the published field face (the old `code/required` ghost warning did exactly
431
+ * that).
432
+ */
433
+ async function runtimeDataWarnings(session, schemaCode, sheetCode, formData, row = 1) {
434
+ const warnings = [];
435
+ let fields = [];
436
+ try {
437
+ fields = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
438
+ }
439
+ catch {
440
+ return warnings;
441
+ }
442
+ const byCode = new Map(fields.map((field) => [String(field.code ?? field.propertyCode ?? ""), field]));
443
+ let required = new Set();
444
+ try {
445
+ const sheet = ((await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode, sheetCode } })).data ?? {});
446
+ const face = typeof sheet.publishedAttributesJson === "string" ? sheet.publishedAttributesJson : typeof sheet.draftAttributesJson === "string" ? sheet.draftAttributesJson : undefined;
447
+ if (face) {
448
+ const controls = JSON.parse(face);
449
+ required = new Set(Object.entries(controls).filter(([, control]) => control?.options?.required === true || typeof control?.options?.requiredFormula === "string").map(([code]) => code));
450
+ }
451
+ }
452
+ catch {
453
+ // Advisory diagnostics must never affect the write.
454
+ }
455
+ for (const [field, value] of Object.entries(formData)) {
456
+ const meta = byCode.get(field);
457
+ if (!meta)
458
+ continue;
459
+ if ((required.has(field) || parseOptions(meta.options).required === true) && (value === null || value === undefined || (typeof value === "string" && value.trim() === ""))) {
460
+ warnings.push({ code: "required-empty", row, fieldCode: field, message: `字段 ${field} 声明必填但本行为空;平台照存,OMC 仅提示` });
461
+ }
462
+ const options = parseOptions(meta.options);
463
+ const optionSet = options.optionsSet && typeof options.optionsSet === "object" ? options.optionsSet : {};
464
+ const choiceCodes = choiceCodesFromFieldOptions(meta, options);
465
+ if (choiceCodes.length > 0 && value !== null && value !== undefined && value !== "") {
466
+ const allowed = new Set(choiceCodes);
467
+ const values = Array.isArray(value) ? value : [value];
468
+ const invalid = values.filter((entry) => !allowed.has(String(entry)));
469
+ if (invalid.length > 0)
470
+ warnings.push({ code: "choice-out-of-range", row, fieldCode: field, value: invalid.map(String), allowed: choiceCodes, message: `字段 ${field} 含未声明选项(实际值:${invalid.map(String).join("、")};合法集:${choiceCodes.join("、")});平台照存,OMC 仅提示` });
471
+ }
472
+ }
473
+ return warnings;
474
+ }
373
475
  function projectApprovalReadback(record, submittedKeys) {
374
476
  if (record === null || typeof record !== "object" || Array.isArray(record))
375
477
  return {};
@@ -921,12 +1023,12 @@ export function registerRuntimeDataLane(tools) {
921
1023
  columns: z.array(z.string().min(1)).optional().describe("列投影别名(=displayFields)"),
922
1024
  // r29b F9: data.load/save/update 都叫 fields;list 面原先只有 displayFields/columns。
923
1025
  fields: z.array(z.string().min(1)).optional().describe("列投影别名(=displayFields;与 data.load/data.save/data.update 的 fields 同语义)"),
924
- orders: z.array(z.object({ field: z.string().min(1), dir: z.enum(["ASC", "DESC"]) })).optional(),
1026
+ orders: z.array(z.object({ field: z.string().min(1), dir: z.enum(["ASC", "DESC"]) })).optional().describe("运行面 query/list 不支持服务端排序;排序请用 listview.configure 的 sorts(视图级)或在本地对 items 排序"),
925
1027
  mobile: z.boolean().optional(),
926
1028
  showTotal: z.boolean().default(true),
927
1029
  page: z.number().int().min(0).default(0),
928
1030
  size: z.number().int().min(1).max(200).default(10),
929
- all: z.boolean().optional().describe("true 时自动翻页聚合全部结果(内部按 size=200 逐页拉取,安全上限 1000 行;超过时回 truncated:true + missingCount,别当全集)——存量盘点/数据核对用,替代手动循环翻页。与 page 互斥(all 固定从第 0 页起)"),
1031
+ all: z.boolean().optional().describe("true 时自动翻页聚合全部结果(size=200 逐页,上限 1000 行,超出 resultTruncated:true);与 page 互斥;默认 size=10,视图 pagingSetting.pageSize 不生效"),
930
1032
  full: z.boolean().optional().describe("内联完整列表结果;默认大结果只返回计数与 detailFile"),
931
1033
  // r31 (2026-10-31 real machine): agents pass `query: {field: value}` as a
932
1034
  // filter — zod would STRIP the unknown key silently and the server
@@ -943,6 +1045,14 @@ export function registerRuntimeDataLane(tools) {
943
1045
  tool: "data.list",
944
1046
  message: '没有 `query` 参数——过滤用 `filter: {"op":"eq","field":"<字段>","value":"<值>"}`(或 `filters` 数组=AND、`filterGroup` 嵌套)。r31 真机:`query:{...}` 被静默忽略、total 不变,极易误判为"无数据/已过滤"。',
945
1047
  });
1048
+ if (args.orders !== undefined)
1049
+ throw new ToolInputError({
1050
+ status: "error",
1051
+ blocker: "invalid-input",
1052
+ tool: "data.list",
1053
+ message: "运行面 query/list 不支持服务端排序;排序请用 listview.configure 的 sorts(视图级)或在本地对 items 排序。",
1054
+ nextCommand: `omc call listview.configure --env <env> --args '${JSON.stringify({ schemaCode: args.schemaCode, sorts: args.orders })}'`,
1055
+ });
946
1056
  let condition;
947
1057
  let fromDsl = false;
948
1058
  try {
@@ -970,6 +1080,8 @@ export function registerRuntimeDataLane(tools) {
970
1080
  throw error;
971
1081
  }
972
1082
  return governedRead(ctx, self, args, async (session) => {
1083
+ const applicationScope = await resolveApplicationScope(session);
1084
+ const application = { applicationCode: applicationScope.code, applicationResolution: applicationResolutionOf(applicationScope) };
973
1085
  // A2:DSL 过滤命中的单选/字典字段(total:0 时给值写法提示)
974
1086
  let choiceFilterFields = [];
975
1087
  // Published field metadata is the sole source for filter propertyType
@@ -1094,7 +1206,7 @@ export function registerRuntimeDataLane(tools) {
1094
1206
  const firstPage = extractBizObjectPage(first.data);
1095
1207
  const total = Number(firstPage.total ?? 0);
1096
1208
  if (total === 0 || firstPage.content.length === 0)
1097
- return { schemaCode: args.schemaCode, total: 0, page: "all", size: 0, pagesFetched: 0, items: [], ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
1209
+ return { schemaCode: args.schemaCode, ...application, total: 0, page: "all", size: 0, pagesFetched: 0, items: [], itemsCount: 0, itemsReturned: 0, resultTruncated: false, itemsExternalized: false, ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
1098
1210
  const rows = [...firstPage.content];
1099
1211
  const target = Math.min(total, CAP);
1100
1212
  let pageIndex = 1;
@@ -1139,6 +1251,7 @@ export function registerRuntimeDataLane(tools) {
1139
1251
  const preview = dataListItemPreview(items);
1140
1252
  return {
1141
1253
  schemaCode: args.schemaCode,
1254
+ ...application,
1142
1255
  total,
1143
1256
  page: "all",
1144
1257
  size: collected.length,
@@ -1146,7 +1259,11 @@ export function registerRuntimeDataLane(tools) {
1146
1259
  // 与普通读同一个口径(r28b 真机):草稿策略必须**从结果集实际统计**,
1147
1260
  // 不能按"有没有查询条件"静态声明——静态声明会与 items 自相矛盾。
1148
1261
  filter: (() => {
1149
- const draftRows = items.filter((item) => String(item.fields.sequenceStatus ?? "") === "DRAFT").length;
1262
+ const draftRows = collected.filter((row) => {
1263
+ const record = row;
1264
+ const data = record.data && typeof record.data === "object" ? record.data : record;
1265
+ return String(data.sequenceStatus ?? record.sequenceStatus ?? "") === "DRAFT";
1266
+ }).length;
1150
1267
  return draftRows > 0
1151
1268
  ? { draftPolicy: "included", draftRows, hint: `本次结果含 ${draftRows} 条 DRAFT 行——total 含草稿,不要当"已提交数据量";判定状态读 data.load 的 sequenceStatus` }
1152
1269
  : { draftPolicy: "unspecified", hint: "本次结果无 DRAFT 行;判定状态以 data.load 的 sequenceStatus 为准" };
@@ -1154,6 +1271,9 @@ export function registerRuntimeDataLane(tools) {
1154
1271
  // 形状常量:脚本按 result.items 取数(与内联态一致),itemsCount 与
1155
1272
  // total 的差就是被截断的条数——不再只靠 size 反推。
1156
1273
  itemsCount: collected.length,
1274
+ itemsReturned: collected.length,
1275
+ resultTruncated: truncatedByCap,
1276
+ itemsExternalized: false,
1157
1277
  // r29b F8 的 ids/itemsPreview 由 compact(外置时)现算,不在这里内联——
1158
1278
  // 内联会让小结果越过 2048B 外置阈值,反而把 items 挤成 []。
1159
1279
  ...(truncatedByCap
@@ -1175,7 +1295,26 @@ export function registerRuntimeDataLane(tools) {
1175
1295
  }
1176
1296
  catch (error) {
1177
1297
  if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode) {
1178
- return { schemaCode: args.schemaCode, total: 0, totalPages: 0, page: args.page ?? 0, size: args.size ?? 10, items: [], ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
1298
+ return { schemaCode: args.schemaCode, ...application, total: 0, totalPages: 0, page: args.page ?? 0, size: args.size ?? 10, items: [], ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
1299
+ }
1300
+ if (error instanceof WireError && error.kind === "business" && error.errcode === 303001) {
1301
+ let available = [];
1302
+ try {
1303
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.listQueryList, query: { schemaCode: args.schemaCode, clientType: "PC" } })).data);
1304
+ available = rows.map((row) => ({ code: row.code ?? null, name: row.name ?? null }));
1305
+ }
1306
+ catch {
1307
+ available = [];
1308
+ }
1309
+ return {
1310
+ status: "refused",
1311
+ blocker: "query-view-not-found",
1312
+ errcode: 303001,
1313
+ schemaCode: args.schemaCode,
1314
+ queryCode: args.queryCode ?? args.schemaCode,
1315
+ available,
1316
+ message: available.length > 0 ? `列表视图不存在:${String(args.queryCode ?? args.schemaCode)};可用视图见 available` : `列表视图不存在:${String(args.queryCode ?? args.schemaCode)};该模型无列表视图`,
1317
+ };
1179
1318
  }
1180
1319
  // r30: a MISSING model answers this face with a generic 50000
1181
1320
  // 服务异常 — indistinguishable from a real outage. One field-face
@@ -1190,9 +1329,13 @@ export function registerRuntimeDataLane(tools) {
1190
1329
  if (probeError instanceof WireError && probeError.kind === "business" && probeError.errcode === 300004) {
1191
1330
  return {
1192
1331
  status: "refused",
1193
- blocker: "wire-business",
1332
+ blocker: "schema-not-found",
1194
1333
  errcode: 300004,
1195
- message: `数据模型不存在(${args.schemaCode})——运行面读取前模型必须已创建并发布字段;先 model.create / configure_model(存在性判据用 model.inspect 的 modelPresent)`,
1334
+ schemaCode: args.schemaCode,
1335
+ exists: false,
1336
+ total: 0,
1337
+ message: `数据模型不存在(${args.schemaCode})——运行面读取前模型必须已创建并发布字段。`,
1338
+ nextCommand: "omc call model.list --args '{}'",
1196
1339
  };
1197
1340
  }
1198
1341
  }
@@ -1250,8 +1393,13 @@ export function registerRuntimeDataLane(tools) {
1250
1393
  else if (totalPages > 0 && requestedPage >= totalPages) {
1251
1394
  pageOutOfRange = true;
1252
1395
  }
1396
+ const draftRows = page.content.filter((row) => {
1397
+ const data = row.data && typeof row.data === "object" ? row.data : row;
1398
+ return String(data.sequenceStatus ?? row.sequenceStatus ?? "") === "DRAFT";
1399
+ }).length;
1253
1400
  const readback = {
1254
1401
  schemaCode: args.schemaCode,
1402
+ ...application,
1255
1403
  ...(projectionEmpty ? { projectionWarning: { reason: "business-columns-null", hint: "模型可能未配置列表视图:data.list 只投影列表列,业务列会全为 null;核对写入结果请用 data.load(或先建列表视图)" } } : {}),
1256
1404
  ...(args.queryCode !== undefined ? { queryCode: args.queryCode } : {}),
1257
1405
  total,
@@ -1266,7 +1414,6 @@ export function registerRuntimeDataLane(tools) {
1266
1414
  // 统一一个枚举键 `draftPolicy`(included | excluded | unspecified),
1267
1415
  // 不再用两个不同键名表达同一件事(那要调用方写两套分支)。
1268
1416
  ...(() => {
1269
- const draftRows = items.filter((item) => String(item.fields.sequenceStatus ?? "") === "DRAFT").length;
1270
1417
  const policy = draftRows > 0
1271
1418
  ? {
1272
1419
  draftPolicy: "included",
@@ -1280,6 +1427,10 @@ export function registerRuntimeDataLane(tools) {
1280
1427
  })(),
1281
1428
  ...(choiceFilterFields.length > 0 && Number(total ?? 0) === 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}),
1282
1429
  totalPages,
1430
+ itemsCount: items.length,
1431
+ itemsReturned: items.length,
1432
+ resultTruncated: false,
1433
+ itemsExternalized: false,
1283
1434
  page: page.page ?? requestedPage,
1284
1435
  size: page.size ?? args.size ?? 20,
1285
1436
  ...(pageOutOfRange ? { pageOutOfRange: true, emptyPage: true } : {}),
@@ -1436,6 +1587,8 @@ export function registerRuntimeDataLane(tools) {
1436
1587
  ...readbackProjectionArgs,
1437
1588
  },
1438
1589
  execute: async (session, args) => {
1590
+ const applicationScope = await resolveApplicationScope(session);
1591
+ const application = { applicationCode: applicationScope.code, applicationResolution: applicationResolutionOf(applicationScope) };
1439
1592
  // F-A (self-audit): the combo used to be silently ignored (the workflow
1440
1593
  // draft branch returns without honoring submit). Refuse before any wire.
1441
1594
  if (args.submit === true && typeof args.workflowCode === "string" && args.workflowCode.trim())
@@ -1550,8 +1703,8 @@ export function registerRuntimeDataLane(tools) {
1550
1703
  readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: returnedId } })).data;
1551
1704
  sequenceStatus = extractSequenceStatus((readbackData ?? {}));
1552
1705
  return {
1553
- performed: { errcode: performed.errcode, bizObjectId: returnedId, workflowInstanceId, workItemId, workItemSource, sheetCode, resolvedFrom, formType: "save-draft-workflow" },
1554
- readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
1706
+ performed: { errcode: performed.errcode, ...application, bizObjectId: returnedId, workflowInstanceId, workItemId, workItemSource, sheetCode, resolvedFrom, formType: "save-draft-workflow" },
1707
+ readback: { ...application, authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
1555
1708
  };
1556
1709
  }
1557
1710
  let performed;
@@ -1579,9 +1732,12 @@ export function registerRuntimeDataLane(tools) {
1579
1732
  // "rule didn't fire" misdiagnosis.
1580
1733
  const savedRecord = normalizeLoadedRecord((readbackData ?? {}));
1581
1734
  const savedEmptyFields = readbackEmptyFields(formData, savedRecord);
1735
+ const dataWarnings = await runtimeDataWarnings(session, args.schemaCode, sheetCode, formData, 1);
1582
1736
  const plainReadback = {
1737
+ ...application,
1583
1738
  authoritative: projectReadbackRecord(savedRecord, args),
1584
1739
  ...(savedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(savedEmptyFields, "data.save") } : {}),
1740
+ ...(dataWarnings.length > 0 ? { warnings: dataWarnings } : {}),
1585
1741
  draftRuleHint: "草稿态(DRAFT)保存不触发业务规则——DRAFT 行跳过 Create/Update 分发(DomainServiceImpl.java:240-248);要一调完成态本调用加 submit:true;已存草稿直提形状:data.submit {schemaCode, bizObjectId: 本读回的 bizObjectId, formData}(submit 自持久化)",
1586
1742
  };
1587
1743
  // r18 retro (G3): one-call COMPLETED creation — the agent goal is usually
@@ -1630,7 +1786,7 @@ export function registerRuntimeDataLane(tools) {
1630
1786
  };
1631
1787
  }
1632
1788
  return {
1633
- performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom },
1789
+ performed: { errcode: performed.errcode, ...application, bizObjectId, sheetCode, resolvedFrom },
1634
1790
  readback: plainReadback,
1635
1791
  };
1636
1792
  },
@@ -1711,9 +1867,9 @@ export function registerRuntimeDataLane(tools) {
1711
1867
  * gone, not that the model is clean — a submit can leave a separate draft row
1712
1868
  * behind. After a delete, read the model's residue so "cleaned up" is a
1713
1869
  * model-level fact (`total:0`). Best-effort: a query failure never fails the
1714
- * delete, but it IS surfaced as `modelResidueError`.
1870
+ * delete, but it IS surfaced under `preExistingRows.error`.
1715
1871
  */
1716
- async function modelResidue(session, schemaCode, sheetCode) {
1872
+ async function preExistingRows(session, schemaCode, sheetCode) {
1717
1873
  try {
1718
1874
  const result = await wireCall(session, {
1719
1875
  path: ENDPOINTS.runtimeQueryList,
@@ -1721,12 +1877,12 @@ export function registerRuntimeDataLane(tools) {
1721
1877
  body: { schemaCode, queryCode: schemaCode, filters: [], mobile: false, page: 0, size: 1, queryVersion: 1, showTotal: true },
1722
1878
  });
1723
1879
  const page = extractBizObjectPage(result.data);
1724
- return { modelResidue: { total: page.total, clean: page.total === 0, sheetCode } };
1880
+ return { preExistingRows: { total: page.total, clean: page.total === 0, sheetCode, note: "含历史既有数据,非本次创建" } };
1725
1881
  }
1726
1882
  catch (error) {
1727
1883
  if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode)
1728
- return { modelResidue: { total: 0, clean: true, sheetCode } };
1729
- return { modelResidue: { total: null, clean: null, sheetCode, modelResidueError: error instanceof Error ? error.message : String(error) } };
1884
+ return { preExistingRows: { total: 0, clean: true, sheetCode, note: "含历史既有数据,非本次创建" } };
1885
+ return { preExistingRows: { total: null, clean: null, sheetCode, note: "含历史既有数据,非本次创建", error: error instanceof Error ? error.message : String(error) } };
1730
1886
  }
1731
1887
  }
1732
1888
  writeTool({
@@ -1823,14 +1979,15 @@ export function registerRuntimeDataLane(tools) {
1823
1979
  absent: true,
1824
1980
  deleted: false,
1825
1981
  noop: true,
1826
- recoveryHint: "若另一进程刚刚删除,这是正常并发结果(already-absent),不是 id 拿错。否则:真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏。若 modelResidue.total>0,用 items[].id 重删。",
1827
- ...(await modelResidue(session, args.schemaCode, sheetCode)),
1982
+ recoveryHint: "若另一进程刚刚删除,这是正常并发结果(already-absent),不是 id 拿错。否则:真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏。",
1983
+ thisRow: { absent: true, deleted: false },
1984
+ ...(await preExistingRows(session, args.schemaCode, sheetCode)),
1828
1985
  },
1829
1986
  };
1830
1987
  }
1831
1988
  return {
1832
1989
  performed: { errcode: one.errcode, deleted: one.deleted === true, noop: false, sheetCode, resolvedFrom },
1833
- readback: { absent: one.absent, deleted: one.deleted === true, ...(await modelResidue(session, args.schemaCode, sheetCode)) },
1990
+ readback: { absent: one.absent, deleted: one.deleted === true, thisRow: { absent: one.absent, deleted: one.deleted === true }, ...(await preExistingRows(session, args.schemaCode, sheetCode)) },
1834
1991
  };
1835
1992
  }
1836
1993
  const deleted = [];
@@ -1838,7 +1995,7 @@ export function registerRuntimeDataLane(tools) {
1838
1995
  deleted.push(await deleteOne(id));
1839
1996
  // `deleted`/`count` ride `readback` — the write envelope surfaces only
1840
1997
  // performed/readback inline (extra top-level keys are artifact-only).
1841
- return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1998
+ return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await preExistingRows(session, args.schemaCode, sheetCode)) } };
1842
1999
  },
1843
2000
  });
1844
2001
  writeTool({
@@ -78,6 +78,7 @@ export const WORKFLOW_WIRE = Object.freeze({
78
78
  workItemFilter: "WorkItemSearchVO.filterWorkflowInstanceId is a NOT-equal exclusion (BizWorkitemServiceImpl.toSearchWorkItemsCriterion:400-402), so exact instance filtering is client-side",
79
79
  bizObjectResolution: "GET /api/api/runtime/workflow/getBizObjectByWfInstanceId resolves bizObjectId/schemaCode; a failed lookup leaves the identity null and never drops the todo",
80
80
  mutuallyExclusiveBinding: "同一处理器绑定多条规则时它们**全部执行、无互斥**:审批通过/驳回这类互斥结果不要用两条各自 FIXED 赋值都绑 end(bench22 病灶)——通过绑 end(endEventHandler)或规则 Available,驳回绑 cancel(cancelEventHandler)或规则 Cancel,或用一条多分支图(生产 case_info_update 只在 endEventHandler 绑一条 updateData 多分支图);workflow.bindRule 回读在 bizActions.length>1 时给 advisory",
81
+ participantRoleValidation: "写前参与人校验:PARTICIPANT 的 FindUserByRole(<org>,\"<roleCode>\",\"<order>\") 会与 org.role.list 的真实角色 code 比对;缺失即拒绝 workflow-participant-role-not-found,并给 activityCode/activityName/roleCode、最多 5 个 candidates 与 nextCommand。org.role.list 读取失败时 fail-open,不阻断写入。{Originator} 是发起人关键字,不按角色校验;所有 PARTICIPANT 都是 {Originator} 时给 approval-by-originator warning。",
81
82
  userSearch: "POST /api/api/organization/user/search,@RequestBody {wd (关键字,必填), page, size, filterType?} → PageVO<UserVO>{totalElements,totalPages,content[{id,name,username,userId,departmentId,corpId,status}]}(UserController.search:337-341;缺 wd 报 10005 关键字不能为空)。用于把姓名解析成真实用户,构造参与者对象 type 3=用户(type 2=角色)",
82
83
  });
83
84
  /** Workflow template object (workflow/update|publish raw body). */
@@ -117,6 +118,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
117
118
  bizObjectResolution: WORKFLOW_WIRE.bizObjectResolution,
118
119
  mutuallyExclusiveBinding: WORKFLOW_WIRE.mutuallyExclusiveBinding,
119
120
  userSearch: WORKFLOW_WIRE.userSearch,
121
+ participantRoleValidation: WORKFLOW_WIRE.participantRoleValidation,
120
122
  }),
121
123
  wireBindings: Object.freeze({
122
124
  templateShape: Object.freeze([behaviorBinding("test/ticket-112-workflow-template-merge.test.ts#workflow.update merges onto the draft", assertWorkflowContract)]),
@@ -135,6 +137,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
135
137
  bizObjectResolution: Object.freeze([emitterBinding("workflow-support.resolveBizObjectIdByInstance", assertWorkflowContract)]),
136
138
  mutuallyExclusiveBinding: Object.freeze([behaviorBinding("test/issue-14-workflow-bind-rule.test.ts#workflow.bindRule merges endEventHandler", assertWorkflowContract)]),
137
139
  userSearch: Object.freeze([behaviorBinding("test/round4-org-user-search.test.ts#org.user.search resolves a user by name into participant fields", assertWorkflowContract)]),
140
+ participantRoleValidation: Object.freeze([behaviorBinding("test/workflow-participant-role.test.ts#workflow.update refuses an unknown FindUserByRole role before the wire", assertWorkflowContract)]),
138
141
  }),
139
142
  evidence: Object.freeze([
140
143
  ".scratch/omc-approval-workflow/acceptance/s29-probe-20260910012330184.md",
@@ -160,7 +163,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
160
163
  }),
161
164
  "workflow.update": Object.freeze({
162
165
  scope: WORKFLOW_SCOPE,
163
- description: "写入工作流模板草稿(`/api/api/workflow/update`,raw template JSON body)。cli 以当前草稿为基线做 merge,未列出的既有节点保留:**模板缺 START 或 END 时自动按增量 merge(部分模板),给全 START..END 才整模板替换**;也可显式 `merge:true/false`。**连线三种写法选一**:`routes`(recipe 写法)/ `edges`(同义)/ `rules`(平台 DTO 字段名,`get_draft` 读回原样)——同传两种会被拒(`workflow-routes-ambiguous`);merge 是并集(草稿边 + 你的边),**绝不删边**,若结果会比草稿少边则拒绝(`workflow-merge-would-drop-routes`,回 routesBefore/After + 缺失连线),dry-run 也会给出 `routesBefore/routesAfter/routesAdded/routesDropped`。**节点形状**:新增节点必须给 `type`/`activityType`(可选 START/PARTICIPANT/END…),`activityCode` 命中草稿既有节点时可省(类型/名称继承草稿);**字段权限只发改动行**——`propertyPermissions` 是载荷体积的主要来源(整模板单次常到 1.0–1.4MB,超过 `--args-file` 上限 `CLI_ARGS_MAX_BYTES`=1MB 会被拒),权限编辑只需 `activities:[{activityCode, propertyPermissions:[{propertyCode,…}]}]`。**节点字段权限也走这里**:不必读 `get_draft --full`——直接发 `activities:[{activityCode, propertyPermissions:[{propertyCode, visible, editable, required, summary, subPropertyPermissions}]}]`(**type 可省**:activityCode 命中草稿节点时类型/名称继承草稿,不会铸 `_2` 新码;**改名=同 code 原地生效**——工具把新名写入 `name_i18n`(zh/zh_TW/en),activityName 平台侧保持首建值,读回以 `renamedInPlace.displayName` 为准;只有**类型变更**才铸新码并弃旧码),工具按 propertyCode 合并到草稿对应节点(未列的字段权限保留;`merge:true` 也可同用)——没有单独的权限工具,别把「找不到入口」当成不支持(dogfood 实测该误判会假阻塞)。`summary` **无需配置**:工具合并时自动补 `summary:true`(平台怪癖——缺省进不了运行时 `activitySummaries`、`form/load` 读不到你的 `editable:false`;显式传 `false` 才保留);子表字段用 `subPropertyPermissions`(同样自动补)。**子表属性不可设 `required:true`**(接口形状接受但设计器节点字段配置不支持,存入后无法渲染——工具直接拒 `workflow-child-table-not-required`);子表整行只配 visible/editable,字段级必填配到子表内部字段所在节点。**新增节点用语义 code**(如 `approval_finance`),不要模仿草稿的 `ActivityN` 再加 `_1/_2` 后缀。**设计器默认自动补齐**(调用方显式传值优先):填写节点({Originator})自动生成子表操作按钮 `buttonPermission.childTableBtns`(每个子表 ADD/DELETE/IMPORT/EXPORT 启用、BATCH_UPDATE 禁用——API 建的流程缺这份模板,设计器里子表按钮会全部不勾);`permittedAction` 归一为设计器默认(填写节点 showReject:false 不能驳回自己,审批节点 rejectToStart:true 驳回回发起节点)。用 `npm run test:live-permission`(非管理员参与者)验收,管理员会被后端强制 `e:true` 掩盖。",
166
+ description: "写入工作流模板草稿(`/api/api/workflow/update`,raw template JSON body)。cli 以当前草稿为基线做 merge,未列出的既有节点保留:**模板缺 START 或 END 时自动按增量 merge(部分模板),给全 START..END 才整模板替换**;也可显式 `merge:true/false`。**连线三种写法选一**:`routes`(recipe 写法)/ `edges`(同义)/ `rules`(平台 DTO 字段名,`get_draft` 读回原样)——同传两种会被拒(`workflow-routes-ambiguous`);merge 是并集(草稿边 + 你的边),**绝不删边**,若结果会比草稿少边则拒绝(`workflow-merge-would-drop-routes`,回 routesBefore/After + 缺失连线),dry-run 也会给出 `routesBefore/routesAfter/routesAdded/routesDropped`。**节点形状**:新增节点必须给 `type`/`activityType`(可选 START/PARTICIPANT/END…),`activityCode` 命中草稿既有节点时可省(类型/名称继承草稿);**字段权限只发改动行**——`propertyPermissions` 是载荷体积的主要来源(整模板单次常到 1.0–1.4MB,超过 `--args-file` 上限 `CLI_ARGS_MAX_BYTES`=1MB 会被拒)。**节点字段权限也走这里**:不必读 `get_draft --full`——直接发 `activities:[{activityCode, propertyPermissions:[{propertyCode, visible, editable, required, summary, subPropertyPermissions}]}]`(**type 可省**:activityCode 命中草稿节点时类型/名称继承草稿,不会铸 `_2` 新码;**改名=同 code 原地生效**——工具把新名写入 `name_i18n`(zh/zh_TW/en),activityName 平台侧保持首建值,读回以 `renamedInPlace.displayName` 为准;只有**类型变更**才铸新码并弃旧码),工具按 propertyCode 合并到草稿对应节点(未列的字段权限保留;`merge:true` 也可同用)——没有单独的权限工具,别把「找不到入口」当成不支持(dogfood 实测该误判会假阻塞)。`summary` **无需配置**:工具合并时自动补 `summary:true`(平台怪癖——缺省进不了运行时 `activitySummaries`、`form/load` 读不到你的 `editable:false`;显式传 `false` 才保留);子表字段用 `subPropertyPermissions`(同样自动补)。**参与人写前校验**:`FindUserByRole(…,\"<roleCode>\",…)` 与 org.role.list 比对,未知角色拒 `workflow-participant-role-not-found`(带候选);全 `{Originator}` 给 `approval-by-originator` warning。**子表属性不可设 `required:true`**(接口形状接受但设计器节点字段配置不支持,存入后无法渲染——工具直接拒 `workflow-child-table-not-required`);子表整行只配 visible/editable,字段级必填配到子表内部字段所在节点。**新增节点用语义 code**(如 `approval_finance`),不要模仿草稿的 `ActivityN` 再加 `_1/_2` 后缀。**设计器默认自动补齐**(调用方显式传值优先):填写节点({Originator})自动生成子表操作按钮 `buttonPermission.childTableBtns`(每个子表 ADD/DELETE/IMPORT/EXPORT 启用、BATCH_UPDATE 禁用——API 建的流程缺这份模板,设计器里子表按钮会全部不勾);`permittedAction` 归一为设计器默认(填写节点 showReject:false 不能驳回自己,审批节点 rejectToStart:true 驳回回发起节点)。",
164
167
  }),
165
168
  "workflow.publish": Object.freeze({
166
169
  scope: WORKFLOW_SCOPE,
@@ -241,6 +244,7 @@ export function assertWorkflowContract() {
241
244
  assertDescriptionCarries(WORKFLOW_LANE_CONTRACT, "org.role.list", ["organization/role/list", "type", "Originator"]);
242
245
  assertDescriptionCarries(WORKFLOW_LANE_CONTRACT, "org.department.list", ["organization/department/tree", "parentId"]);
243
246
  assertDescriptionCarries(WORKFLOW_LANE_CONTRACT, "org.user.search", ["organization/user/search", "wd", "type 3"]);
247
+ assertDescriptionCarries(WORKFLOW_LANE_CONTRACT, "workflow.update", ["FindUserByRole", "workflow-participant-role-not-found", "approval-by-originator"]);
244
248
  for (const [key, field] of Object.entries(WORKFLOW_EVENT_HANDLER_FIELDS)) {
245
249
  if (!WORKFLOW_WIRE.bindRuleChain.includes(key))
246
250
  throw new Error(`workflow contract bind-rule chain must mention the ${key} handler key`);
@@ -767,6 +767,18 @@ function participantReferenceWarnings(template) {
767
767
  }
768
768
  return warnings;
769
769
  }
770
+ export function approvalByOriginatorWarnings(template) {
771
+ const activities = Array.isArray(template.activities) ? template.activities : [];
772
+ const participants = activities.filter((activity) => String(activity.activityType ?? "") === "PARTICIPANT");
773
+ if (participants.length === 0 || !participants.every((activity) => String(activity.participant ?? "").trim() === "{Originator}"))
774
+ return [];
775
+ const activityCodes = participants.map((activity) => String(activity.activityCode ?? "")).filter((code) => code.length > 0);
776
+ return [{
777
+ code: "approval-by-originator",
778
+ activityCodes,
779
+ message: `所有参与人节点(${activityCodes.join("、")})都指向 {Originator}:该流程会由发起人自审自批;这是允许的单人流程,但请确认该行为符合业务意图。`,
780
+ }];
781
+ }
770
782
  /**
771
783
  * Role participants are filtered by `ouScope ∩ originator's department chain`.
772
784
  * A role whose members all have an empty scope therefore resolves to NOBODY —
@@ -782,12 +794,15 @@ async function assertRoleParticipantScopes(session, body) {
782
794
  ? Object.values(body.activities)
783
795
  : [];
784
796
  const roles = [];
797
+ const functionRoles = [];
785
798
  for (const activity of rows) {
786
799
  if (String(activity.activityType ?? "") !== "PARTICIPANT")
787
800
  continue;
788
801
  const raw = activity.participant;
789
802
  if (typeof raw !== "string" || !raw.trim().startsWith("["))
790
803
  continue;
804
+ const activityCode = String(activity.activityCode ?? "");
805
+ const activityName = String(activity.activityName ?? activity.name ?? activityCode);
791
806
  for (const segment of raw.split("+")) {
792
807
  let parsed;
793
808
  try {
@@ -806,7 +821,86 @@ async function assertRoleParticipantScopes(session, body) {
806
821
  continue;
807
822
  const roleId = String(record.id ?? "");
808
823
  if (roleId)
809
- roles.push({ activityCode: String(activity.activityCode ?? ""), roleId, roleName: String(record.name ?? roleId) });
824
+ roles.push({ activityCode, activityName, roleId, roleName: String(record.name ?? roleId) });
825
+ }
826
+ }
827
+ }
828
+ for (const activity of rows) {
829
+ if (String(activity.activityType ?? "") !== "PARTICIPANT")
830
+ continue;
831
+ const participant = typeof activity.participant === "string" ? activity.participant.trim() : "";
832
+ const match = /^FindUserByRole\s*\(\s*[^,]+?\s*,\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*,\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\)\s*$/.exec(participant);
833
+ if (!match)
834
+ continue;
835
+ const quoted = match[1];
836
+ let roleCode;
837
+ try {
838
+ roleCode = quoted.startsWith('"') ? JSON.parse(quoted) : quoted.slice(1, -1).replace(/\\(['\\])/g, "$1");
839
+ }
840
+ catch {
841
+ continue;
842
+ }
843
+ if (roleCode.trim())
844
+ functionRoles.push({ activityCode: String(activity.activityCode ?? ""), activityName: String(activity.activityName ?? activity.name ?? activity.activityCode ?? ""), roleCode: roleCode.trim() });
845
+ }
846
+ if (functionRoles.length > 0) {
847
+ let listedRoles = [];
848
+ let roleListAvailable = false;
849
+ try {
850
+ const data = (await wireCall(session, { path: ENDPOINTS.organizationRoleList, query: { expandAll: "true" } })).data;
851
+ const flattened = flattenRoleTree(wireRows(data));
852
+ listedRoles = Array.isArray(flattened.roles) ? flattened.roles : [];
853
+ roleListAvailable = true;
854
+ }
855
+ catch {
856
+ // Role existence is advisory when the organization read face is unavailable.
857
+ // Do not turn a transient read failure into a blanket workflow write outage.
858
+ roleListAvailable = false;
859
+ }
860
+ if (roleListAvailable) {
861
+ const byCode = new Map(listedRoles
862
+ .map((role) => [String(role.code ?? ""), role])
863
+ .filter(([code]) => code.length > 0));
864
+ const missing = functionRoles.filter((role) => !byCode.has(role.roleCode));
865
+ if (missing.length > 0) {
866
+ const candidates = (needle) => {
867
+ const lower = needle.toLowerCase();
868
+ const distance = (left, right) => {
869
+ const row = Array.from({ length: right.length + 1 }, (_, index) => index);
870
+ for (let i = 1; i <= left.length; i++) {
871
+ let diagonal = row[0];
872
+ row[0] = i;
873
+ for (let j = 1; j <= right.length; j++) {
874
+ const prior = row[j];
875
+ row[j] = left[i - 1] === right[j - 1] ? diagonal : Math.min(row[j] + 1, row[j - 1] + 1, diagonal + 1);
876
+ diagonal = prior;
877
+ }
878
+ }
879
+ return row[right.length];
880
+ };
881
+ return listedRoles
882
+ .map((role) => {
883
+ const code = String(role.code ?? "");
884
+ const name = String(role.name ?? "");
885
+ const haystack = `${code} ${name}`.toLowerCase();
886
+ const substringRank = haystack.includes(lower) ? 0 : 1;
887
+ const editRank = Math.min(distance(lower, code.toLowerCase()), distance(lower, name.toLowerCase()));
888
+ return { code, name, substringRank, editRank };
889
+ })
890
+ .filter((role) => role.code.length > 0)
891
+ .sort((left, right) => left.substringRank - right.substringRank || left.editRank - right.editRank || left.code.localeCompare(right.code))
892
+ .slice(0, 5)
893
+ .map(({ code, name }) => ({ code, name }));
894
+ };
895
+ const first = missing[0];
896
+ throw new ToolRefusalError({
897
+ status: "refused",
898
+ blocker: "workflow-participant-role-not-found",
899
+ roles: missing,
900
+ candidates: candidates(first.roleCode),
901
+ nextCommand: "omc call org.role.list --args '{}'",
902
+ message: `写前拒绝:节点 ${first.activityCode}(${first.activityName})引用了不存在的角色 ${first.roleCode}。放行会导致待办静默回退给发起人(r9/r10/r15 真机)。请先用 org.role.list 确认真实 role code。`,
903
+ });
810
904
  }
811
905
  }
812
906
  }
@@ -2359,12 +2453,14 @@ export function registerWorkflowLane(tools) {
2359
2453
  return { schemaCode: args.schemaCode, exists: false, note: "该模型没有流程可校验" };
2360
2454
  const template = (args.template ?? {});
2361
2455
  const body = await mergedTemplateBody(session, args.schemaCode ?? "", workflowCode, template, activitiesLackBoundaries(template));
2456
+ await assertRoleParticipantScopes(session, body);
2362
2457
  const result = await wireCall(session, { path: ENDPOINTS.workflowValidate, method: "POST", body });
2363
2458
  const messages = Array.isArray(result.data) ? result.data : [];
2364
2459
  const errors = messages.filter((message) => message.errorLevel === "ERROR");
2365
2460
  const warnings = messages.filter((message) => message.errorLevel !== "ERROR");
2366
2461
  // r31c F23: local reachability / else-coverage — warning only, never flips valid.
2367
2462
  const reachability = workflowReachabilityWarnings(body);
2463
+ const originatorWarnings = approvalByOriginatorWarnings(body);
2368
2464
  const allWarnings = [
2369
2465
  ...warnings,
2370
2466
  ...reachability.map((entry) => ({
@@ -2375,6 +2471,7 @@ export function registerWorkflowLane(tools) {
2375
2471
  name: entry.name,
2376
2472
  problem: entry.problem,
2377
2473
  })),
2474
+ ...originatorWarnings.map((entry) => ({ errorLevel: "WARNING", errCode: entry.code, errContent: entry.message, activityCodes: entry.activityCodes })),
2378
2475
  ];
2379
2476
  return {
2380
2477
  workflowCode,
@@ -2384,6 +2481,7 @@ export function registerWorkflowLane(tools) {
2384
2481
  errors,
2385
2482
  warnings: allWarnings,
2386
2483
  ...(reachability.length > 0 ? { reachabilityWarnings: reachability } : {}),
2484
+ ...(originatorWarnings.length > 0 ? { approvalWarnings: originatorWarnings } : {}),
2387
2485
  };
2388
2486
  }, { target: `workflow.validate:${args.workflowCode ?? args.schemaCode}` }),
2389
2487
  }));
@@ -2486,6 +2584,7 @@ export function registerWorkflowLane(tools) {
2486
2584
  // Evaluate on `body` (post-backfill): the readback may drop the handler
2487
2585
  // keys, and the backfilled permittedAction is exactly what will run.
2488
2586
  const rejectWarnings = rejectToStartCancelWarnings(body);
2587
+ const originatorWarnings = approvalByOriginatorWarnings(body);
2489
2588
  const renamedActivities = renamedActivityPairs(template, data);
2490
2589
  const renamedInPlace = inPlaceRenames(data);
2491
2590
  // Caller sent no routes at all: the draft's edges (branches included) are
@@ -2503,7 +2602,7 @@ export function registerWorkflowLane(tools) {
2503
2602
  const activityNames = Object.fromEntries(rawActivityRows
2504
2603
  .map((row) => [String(row.activityCode ?? ""), effectiveActivityName(row)])
2505
2604
  .filter(([code]) => code !== ""));
2506
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, rollbackHint: `本次改动的 before 快照已记录:omc rollback plan --target workflow:${args.schemaCode}/${args.code} 查看可回退项,omc rollback apply --target … --step <n> 执行(草稿回退后仍需 workflow.publish 才生效)`, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2605
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, rollbackHint: `本次改动的 before 快照已记录:omc rollback plan --target workflow:${args.schemaCode}/${args.code} 查看可回退项,omc rollback apply --target … --step <n> 执行(草稿回退后仍需 workflow.publish 才生效)`, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length || originatorWarnings.length ? { workflowWarnings: [...rejectWarnings, ...originatorWarnings] } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2507
2606
  },
2508
2607
  preflight: (args) => {
2509
2608
  // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
@@ -2592,6 +2691,7 @@ export function registerWorkflowLane(tools) {
2592
2691
  const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
2593
2692
  await backfillDesignerDefaults(session, args.schemaCode, body, template);
2594
2693
  assertFirstActivityIsOriginator(template, "workflow.publish", body);
2694
+ await assertRoleParticipantScopes(session, body);
2595
2695
  const unregistered = unregisteredParticipantFunctions(body);
2596
2696
  if (unregistered.length > 0)
2597
2697
  throw new ToolRefusalError({
@@ -2629,7 +2729,8 @@ export function registerWorkflowLane(tools) {
2629
2729
  validateWorkflowRender(data, "workflow.publish readback");
2630
2730
  const participantWarnings = participantReferenceWarnings(data);
2631
2731
  const rejectWarnings = rejectToStartCancelWarnings(data);
2632
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2732
+ const originatorWarnings = approvalByOriginatorWarnings(data);
2733
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length || originatorWarnings.length ? { workflowWarnings: [...rejectWarnings, ...originatorWarnings] } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2633
2734
  },
2634
2735
  preflight: (args) => {
2635
2736
  assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");