@stalfh233/omc-cli 0.6.15 → 0.6.17

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.
@@ -141,12 +141,25 @@ export function registerBusinessRuleLane(tools) {
141
141
  // key (schemaCode+ruleCode), so the row id is rarely needed.
142
142
  const fullRows = args.full === true;
143
143
  const visible = args.onlyCustom === true ? rows.filter((row) => row.defaultRule !== true) : rows;
144
+ const statusLag = visible.some((row) => row.enabled === true && String(row.status ?? "") !== "ENABLED");
144
145
  return {
145
146
  schemaCode: args.schemaCode,
146
147
  modelPresent: true,
147
148
  // r28g F18:list 投影的是 PUBLISH 行;运行时可执行性以 rule.get / rule.enable.verified 为准。
148
149
  source: "published-list",
149
150
  executableHint: "rules[].enabled/status 来自 PUBLISH 投影;运行时判定读 DRAFT(rule.get 的 enabled+ruleStatus,或 rule.enable 回执的 executable/verified)。不要仅凭本面判定可 rule.run。",
151
+ ...(statusLag
152
+ ? {
153
+ statusLag: true,
154
+ statusNote: "本面 status 是 PUBLISH 投影列,与 rule.get 的 ruleStatus 可不同;检测到 enabled=true 而 status 非 ENABLED 的滞后行——enabled=true 才是分发真相,运行时以 rule.get/enable 复核",
155
+ }
156
+ : {
157
+ // r35a-F7(真机 2026-11-06):`status` 与 rule.get 的 `ruleStatus` 是
158
+ // **不同 wire 列**,可一个 ENABLED 一个 DISABLED(探针实测
159
+ // list status=ENABLED/ruleVersion=1 vs get ruleStatus=DISABLED/0)。
160
+ // 常态化给一句短注(~90B/次),避免把本面 status 当运行时真相。
161
+ statusNote: "本面 status 是 PUBLISH 投影列,与 rule.get 的 ruleStatus 可不同;运行时是否生效以 rule.get/enable 为准",
162
+ }),
150
163
  ...(args.onlyCustom === true ? { filtered: { onlyCustom: true, total: rows.length, returned: visible.length } } : {}),
151
164
  ...(endpointFamily ? { endpointFamily } : {}),
152
165
  rules: visible.map((row) => ({
@@ -154,6 +167,7 @@ export function registerBusinessRuleLane(tools) {
154
167
  name: row.name,
155
168
  enabled: row.enabled,
156
169
  status: row.status,
170
+ ...(row.enabled === true && String(row.status ?? "") !== "ENABLED" ? { statusLag: true } : {}),
157
171
  ...(row.ruleVersion !== undefined ? { ruleVersion: row.ruleVersion } : {}),
158
172
  ...(row.defaultRule !== undefined ? { defaultRule: row.defaultRule, preset: row.defaultRule === true } : {}),
159
173
  // ticket-80's update-mode readback teaches following the same-code
@@ -270,6 +284,14 @@ export function registerBusinessRuleLane(tools) {
270
284
  code: wanted,
271
285
  scanned: { models: scoped.length, rules: scannedRules, ...(parseFailures > 0 ? { parseFailures } : {}) },
272
286
  matches,
287
+ // r35a-F4(真机 2026-11-06):matches:[] 曾被当成「可安全删除」的依据,
288
+ // 而探针实测该字段**被表单引用**(form.get draftControlCodes)且出现在
289
+ // 流程节点权限里——本面两者都没扫。散文字段不承担安全契约:结构化声明。
290
+ coverage: {
291
+ scanned: ["businessrule"],
292
+ notScanned: ["datarule", "form", "workflow.permissions", "workflow.bindings", "onlinejs", "listview"],
293
+ note: "matches:[] 只代表 businessrule 面零命中;datarule/form/流程权限/视图等未扫描(离线 graph references 覆盖更广,见其 coverage),不可据此判定可安全删除",
294
+ },
273
295
  ...(scope !== undefined ? { applicationResolution: applicationResolutionOf(scope) } : {}),
274
296
  ...(matches.length === 0
275
297
  ? {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
3
  import { fieldChangeWireBody, fieldUpdateWireBody, prepareFieldChange, prepareFieldUpdate } from "../field-change.js";
4
- import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, } from "../field-families.js";
4
+ import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, assertFieldFamilyAllowed, assertSelectionChoicesAllowed, } from "../field-families.js";
5
5
  import { capabilityArg, environmentArg } from "../args.js";
6
6
  import { governedRead } from "../read.js";
7
7
  import { writeTool } from "../write.js";
@@ -442,6 +442,10 @@ export function registerFieldLane(tools) {
442
442
  },
443
443
  };
444
444
  },
445
+ preflight: (args) => {
446
+ assertSelectionChoicesAllowed(args);
447
+ assertFieldFamilyAllowed(args);
448
+ },
445
449
  rollback: fieldRollbackSpec("field.create"),
446
450
  });
447
451
  writeTool({
@@ -732,10 +736,12 @@ async function fieldUpdateDryRun(session, args) {
732
736
  }),
733
737
  };
734
738
  }
739
+ const impact = await collectFieldDeleteImpact(session, args, { full: true });
735
740
  return {
736
741
  wouldChange: changes.length > 0,
737
742
  changes,
738
743
  ...(dataImpact !== undefined ? { dataImpact } : {}),
744
+ impact,
739
745
  schemaCode: args.schemaCode,
740
746
  code: args.code,
741
747
  };
@@ -951,6 +957,11 @@ async function collectFieldDeleteImpact(session, args, options) {
951
957
  ...(counted.note ? { dataRowsNote: counted.note } : counted.dataRows === "unknown" ? { dataRowsNote: "拿不到行数:data.list total 不可用" } : {}),
952
958
  references,
953
959
  dependentUpdates: surfaces.dependentUpdates,
960
+ coverage: {
961
+ scanned: options.full ? ["businessrule", "form", "listview", "onlinejs", "data"] : ["form", "listview", "onlinejs", "data"],
962
+ notScanned: options.full ? ["workflow.permissions", "workflow.bindings", "rule.conditions"] : ["businessrule", "workflow.permissions", "workflow.bindings", "rule.conditions"],
963
+ coveredSurfaces: options.full ? ["businessrule", "form", "listview", "onlinejs", "data"] : ["form", "listview", "onlinejs", "data"],
964
+ },
954
965
  ...(unknowns.length > 0 ? { unknown: unknowns } : {}),
955
966
  note: options.full
956
967
  ? "完整影响面(规则+表单+视图+在线JS+行数)。删除不会自动清理依赖面残留。"
@@ -1062,6 +1073,7 @@ async function runFieldUpdate(session, args) {
1062
1073
  ...(args.propertyEmpty !== undefined ? ["propertyEmpty"] : []),
1063
1074
  ];
1064
1075
  const unverifiable = submittedPropertyEmpty !== undefined && !hasReadBackPropertyEmpty ? ["propertyEmpty"] : [];
1076
+ const impact = await collectFieldDeleteImpact(session, args, { full: false });
1065
1077
  return {
1066
1078
  performed: { errcode: performed.errcode },
1067
1079
  ...(prepared.conditionCompiled !== undefined && prepared.conditionCompiled.length > 0
@@ -1069,6 +1081,7 @@ async function runFieldUpdate(session, args) {
1069
1081
  : {}),
1070
1082
  readback: {
1071
1083
  face: "draft",
1084
+ impact,
1072
1085
  fieldPresent: after !== undefined,
1073
1086
  ...(after?.name !== undefined ? { name: after.name } : {}),
1074
1087
  ...(typeof after?.name_i18n === "string" ? { name_i18n: after.name_i18n } : {}),
@@ -476,23 +476,6 @@ export function registerListViewLane(tools) {
476
476
  schemaCode: z.string().min(1),
477
477
  name: z.string().min(1),
478
478
  code: z.string().optional().describe("视图 code;省略时**默认取 schemaCode**(与 listview.get / listview.list / rollback 的默认视图口径一致)。平台侧 code 必填,本工具会替你填,不要再依赖不回执的猜测(r26 真机:不传直接被 errcode 10005「编码不能为空」拒绝,白跑一轮线上往返)"),
479
- // r29b F5a: create 没有列参数。接受 fieldCodes 只是为了给可执行 nextCommand,
480
- // 避免未知键算法给出「fieldCodes→code」这种南辕北辙的建议。
481
- fieldCodes: z.array(z.string()).optional().describe("本工具不配列——若传了会拒绝并指引 listview.configure"),
482
- },
483
- // dry-run 不跑 execute——fieldCodes 必须在 preflight 拦,否则 dry-run 仍会放行。
484
- preflight: (args) => {
485
- if (Array.isArray(args.fieldCodes) && args.fieldCodes.length > 0) {
486
- const code = args.code ?? args.schemaCode;
487
- throw new ToolInputError({
488
- status: "error",
489
- blocker: "tool-input-invalid",
490
- tool: "listview.create",
491
- message: "listview.create 不接受列参数:先 create(schemaCode/name/code),再 listview.configure 配 fieldCodes。" +
492
- "不要把 fieldCodes 当成 create 的拼写错误去改成 code。",
493
- nextCommand: `omc call listview.configure --args '${JSON.stringify({ schemaCode: args.schemaCode, code, fieldCodes: args.fieldCodes })}'`,
494
- });
495
- }
496
479
  },
497
480
  execute: async (session, args) => {
498
481
  // r26 真机:`code` 在 schema 里可选,但三处读面/rollback 都用
@@ -500,16 +483,6 @@ export function registerListViewLane(tools) {
500
483
  // `errcode 10005 编码不能为空`,探针白跑一轮。默认视图 code 就是
501
484
  // schemaCode(listview.list 的 hint 也这么写),这里对齐。
502
485
  const code = args.code ?? args.schemaCode;
503
- if (Array.isArray(args.fieldCodes) && args.fieldCodes.length > 0) {
504
- throw new ToolInputError({
505
- status: "error",
506
- blocker: "tool-input-invalid",
507
- tool: "listview.create",
508
- message: "listview.create 不接受列参数:先 create(schemaCode/name/code),再 listview.configure 配 fieldCodes。" +
509
- "不要把 fieldCodes 当成 create 的拼写错误去改成 code。",
510
- nextCommand: `omc call listview.configure --args '${JSON.stringify({ schemaCode: args.schemaCode, code, fieldCodes: args.fieldCodes })}'`,
511
- });
512
- }
513
486
  // Ticket 81: read the header FIRST — a same-code retry short-circuits
514
487
  // with the existing view and sends ZERO create wires (the live
515
488
  // post-timeout retry otherwise answers 303002 编码重复).
@@ -1,6 +1,6 @@
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, } from "../field-families.js";
4
4
  import { fieldChangeWireBody, prepareFieldChange, sameFieldChange } from "../field-change.js";
5
5
  import { executeFormDraftWrite, executeFormPublishWrite } from "../form-support.js";
6
6
  import { mapBounded } from "../support.js";
@@ -393,7 +393,11 @@ export function registerModelLane(tools) {
393
393
  // delete). Same SYSTEM_FIELD_CODES set field.list uses.
394
394
  const visibleColumns = all ? listviewColumns : listviewColumns.filter((column) => !SYSTEM_FIELD_CODES.has(String(column.propertyCode)));
395
395
  const listview = listviewFace && listviewFace.columns
396
- ? { code: listviewFace.code ?? schemaCode, columnCount: visibleColumns.length, columns: visibleColumns.map((column) => ({ propertyCode: column.propertyCode, propertyType: column.propertyType, visible: column.visible })) }
396
+ ? {
397
+ code: listviewFace.code ?? schemaCode,
398
+ columnCount: visibleColumns.length,
399
+ columns: visibleColumns.map((column) => ({ propertyCode: column.propertyCode, propertyType: column.propertyType, visible: column.visible })),
400
+ }
397
401
  : undefined;
398
402
  const ruleRows = Array.isArray(ruleFace?.rules) ? ruleFace.rules : [];
399
403
  // All rules are kept: the platform pre-creates trigger rows (Create/Update/
@@ -433,6 +437,7 @@ export function registerModelLane(tools) {
433
437
  const workflow = draftFace?.exists === false || (draftFace === undefined && publishedFace === undefined)
434
438
  ? (draftFace && draftFace.schemaCode ? { exists: false } : undefined)
435
439
  : {
440
+ exists: true,
436
441
  workflowCode: summary?.workflowCode ?? workflowCode,
437
442
  published: publishedFace?.published === true || pubSummary?.templateType === "PUBLISH",
438
443
  activities: Array.isArray(summary?.activities) ? summary.activities.map((activity) => ({ code: activity.code, type: activity.type, name: activity.name, participant: activity.participant })) : [],
@@ -447,6 +452,7 @@ export function registerModelLane(tools) {
447
452
  const absent = fieldRefusalErrcode === 300004 || faceErrcodes.get("field.list") === 300004;
448
453
  const modelPresent = absent ? false : fieldFace !== undefined && !fieldRefused ? true : undefined;
449
454
  return {
455
+ code: schemaCode,
450
456
  schemaCode,
451
457
  ...(modelName !== undefined ? { name: modelName } : {}),
452
458
  ...(modelPresent !== undefined ? { modelPresent } : {}),
@@ -482,12 +488,24 @@ export function registerModelLane(tools) {
482
488
  return result;
483
489
  return {
484
490
  schemaCode: result.schemaCode,
491
+ // r35a-F3:compact 投影必须保留 code(model.list 有 code,inspect 此前
492
+ // 只剩 schemaCode,`--jq '[].code'` 必 pathMiss 白跑一轮)。
493
+ ...(result.code !== undefined ? { code: result.code } : {}),
485
494
  fieldCodes: (result.fields ?? []).map((field) => field.code),
486
495
  form: result.form,
487
496
  listview: result.listview ? { code: result.listview.code, columnCount: result.listview.columnCount } : undefined,
488
497
  ruleCodes: (result.rules ?? []).map((rule) => rule.code),
489
498
  datarules: result.datarules,
490
- workflow: result.workflow ? { workflowCode: result.workflow.workflowCode, published: result.workflow.published } : undefined,
499
+ // r35a-F2:workflow 摘要必须恒有 exists 布尔——compact 投影此前把它丢了,
500
+ // 真机(走 compact 的分支)于是永远读不到 exists(探针只能靠
501
+ // missedEntries 反推「缺失=有流程」,语义反直觉且有安全风险)。
502
+ workflow: result.workflow
503
+ ? {
504
+ exists: result.workflow.exists !== false,
505
+ workflowCode: result.workflow.workflowCode,
506
+ published: result.workflow.published,
507
+ }
508
+ : { exists: false },
491
509
  hint: result.hint,
492
510
  };
493
511
  },
@@ -1299,6 +1317,8 @@ export function registerModelLane(tools) {
1299
1317
  ];
1300
1318
  if (violations.length > 0)
1301
1319
  throw new ToolRefusalError({ status: "refused", blocker: "business-code-invalid", message: "code 未过出门前校验;按 validation 修正(长度/风格/保留字)", validation: violations });
1320
+ for (const entry of entries)
1321
+ assertSelectionChoicesAllowed(entry);
1302
1322
  for (const entry of entries)
1303
1323
  prepareFieldChange(fieldChangeInputOf(args.code, entry));
1304
1324
  },
@@ -1495,8 +1515,10 @@ function fieldEntriesOf(args) {
1495
1515
  if (Array.isArray(args.fields) && args.fields.length > 0) {
1496
1516
  if (args.fieldCode !== undefined || args.fieldName !== undefined || args.propertyType !== undefined)
1497
1517
  throw new ToolRefusalError({ status: "refused", blocker: "configure-model-fields-exclusive", message: "fields[] 与单字段参数(fieldCode/fieldName/propertyType)互斥;多字段用 fields[],单字段用旧三元组" });
1498
- for (const entry of args.fields)
1518
+ for (const entry of args.fields) {
1519
+ assertSelectionChoicesAllowed(entry);
1499
1520
  assertFieldFamilyAllowed({ ...entry });
1521
+ }
1500
1522
  return args.fields.map((entry) => ({ code: entry.code, name: entry.name, propertyType: entry.propertyType, ...withLegacy(entry) }));
1501
1523
  }
1502
1524
  if (typeof args.fieldCode !== "string" || typeof args.fieldName !== "string" || typeof args.propertyType !== "string")
@@ -1506,6 +1528,7 @@ function fieldEntriesOf(args) {
1506
1528
  message: "configure_model 需要 fields[](多字段)或 fieldCode+fieldName+propertyType(单字段)",
1507
1529
  });
1508
1530
  const legacy = { code: args.fieldCode, name: args.fieldName, propertyType: args.propertyType, ...withLegacy(args) };
1531
+ assertSelectionChoicesAllowed(legacy);
1509
1532
  assertFieldFamilyAllowed(legacy);
1510
1533
  return [legacy];
1511
1534
  }
@@ -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 未解析出任何角色组;先确认组织树可读,不要盲建角色" });
@@ -55,6 +55,7 @@ function dataListCompactProjection(result) {
55
55
  if (value.length > 0)
56
56
  compact.listArtifactHint =
57
57
  "items 已外置(truncatedKeys 含 items):完整行见 artifact(detailArtifactId / fetch)。同信封三分法:ids=本页全部 id;itemsPreview=ids 前 3 个的引用(仅 {id},不是第二份行);items=[] 表示正文在 artifact。";
58
+ compact.itemsExternalized = true;
58
59
  continue;
59
60
  }
60
61
  // 这两个键由本函数按 items 现算,handler 不再重复内联。
@@ -921,12 +922,12 @@ export function registerRuntimeDataLane(tools) {
921
922
  columns: z.array(z.string().min(1)).optional().describe("列投影别名(=displayFields)"),
922
923
  // r29b F9: data.load/save/update 都叫 fields;list 面原先只有 displayFields/columns。
923
924
  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(),
925
+ orders: z.array(z.object({ field: z.string().min(1), dir: z.enum(["ASC", "DESC"]) })).optional().describe("运行面 query/list 不支持服务端排序;排序请用 listview.configure 的 sorts(视图级)或在本地对 items 排序"),
925
926
  mobile: z.boolean().optional(),
926
927
  showTotal: z.boolean().default(true),
927
928
  page: z.number().int().min(0).default(0),
928
929
  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 页起)"),
930
+ all: z.boolean().optional().describe("true 时自动翻页聚合全部结果(size=200 逐页,上限 1000 行,超出 resultTruncated:true);与 page 互斥;默认 size=10,视图 pagingSetting.pageSize 不生效"),
930
931
  full: z.boolean().optional().describe("内联完整列表结果;默认大结果只返回计数与 detailFile"),
931
932
  // r31 (2026-10-31 real machine): agents pass `query: {field: value}` as a
932
933
  // filter — zod would STRIP the unknown key silently and the server
@@ -943,6 +944,14 @@ export function registerRuntimeDataLane(tools) {
943
944
  tool: "data.list",
944
945
  message: '没有 `query` 参数——过滤用 `filter: {"op":"eq","field":"<字段>","value":"<值>"}`(或 `filters` 数组=AND、`filterGroup` 嵌套)。r31 真机:`query:{...}` 被静默忽略、total 不变,极易误判为"无数据/已过滤"。',
945
946
  });
947
+ if (args.orders !== undefined)
948
+ throw new ToolInputError({
949
+ status: "error",
950
+ blocker: "invalid-input",
951
+ tool: "data.list",
952
+ message: "运行面 query/list 不支持服务端排序;排序请用 listview.configure 的 sorts(视图级)或在本地对 items 排序。",
953
+ nextCommand: `omc call listview.configure --env <env> --args '${JSON.stringify({ schemaCode: args.schemaCode, sorts: args.orders })}'`,
954
+ });
946
955
  let condition;
947
956
  let fromDsl = false;
948
957
  try {
@@ -1094,7 +1103,7 @@ export function registerRuntimeDataLane(tools) {
1094
1103
  const firstPage = extractBizObjectPage(first.data);
1095
1104
  const total = Number(firstPage.total ?? 0);
1096
1105
  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) } : {}) };
1106
+ return { schemaCode: args.schemaCode, total: 0, page: "all", size: 0, pagesFetched: 0, items: [], itemsCount: 0, itemsReturned: 0, resultTruncated: false, itemsExternalized: false, ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
1098
1107
  const rows = [...firstPage.content];
1099
1108
  const target = Math.min(total, CAP);
1100
1109
  let pageIndex = 1;
@@ -1154,6 +1163,9 @@ export function registerRuntimeDataLane(tools) {
1154
1163
  // 形状常量:脚本按 result.items 取数(与内联态一致),itemsCount 与
1155
1164
  // total 的差就是被截断的条数——不再只靠 size 反推。
1156
1165
  itemsCount: collected.length,
1166
+ itemsReturned: collected.length,
1167
+ resultTruncated: truncatedByCap,
1168
+ itemsExternalized: false,
1157
1169
  // r29b F8 的 ids/itemsPreview 由 compact(外置时)现算,不在这里内联——
1158
1170
  // 内联会让小结果越过 2048B 外置阈值,反而把 items 挤成 []。
1159
1171
  ...(truncatedByCap
@@ -1190,9 +1202,13 @@ export function registerRuntimeDataLane(tools) {
1190
1202
  if (probeError instanceof WireError && probeError.kind === "business" && probeError.errcode === 300004) {
1191
1203
  return {
1192
1204
  status: "refused",
1193
- blocker: "wire-business",
1205
+ blocker: "schema-not-found",
1194
1206
  errcode: 300004,
1195
- message: `数据模型不存在(${args.schemaCode})——运行面读取前模型必须已创建并发布字段;先 model.create / configure_model(存在性判据用 model.inspect 的 modelPresent)`,
1207
+ schemaCode: args.schemaCode,
1208
+ exists: false,
1209
+ total: 0,
1210
+ message: `数据模型不存在(${args.schemaCode})——运行面读取前模型必须已创建并发布字段。`,
1211
+ nextCommand: "omc call model.list --args '{}'",
1196
1212
  };
1197
1213
  }
1198
1214
  }
@@ -1280,6 +1296,10 @@ export function registerRuntimeDataLane(tools) {
1280
1296
  })(),
1281
1297
  ...(choiceFilterFields.length > 0 && Number(total ?? 0) === 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}),
1282
1298
  totalPages,
1299
+ itemsCount: items.length,
1300
+ itemsReturned: items.length,
1301
+ resultTruncated: false,
1302
+ itemsExternalized: false,
1283
1303
  page: page.page ?? requestedPage,
1284
1304
  size: page.size ?? args.size ?? 20,
1285
1305
  ...(pageOutOfRange ? { pageOutOfRange: true, emptyPage: true } : {}),
@@ -2238,6 +2258,8 @@ export function registerRuntimeDataLane(tools) {
2238
2258
  ...readback,
2239
2259
  workflowInstanceId: effectiveInstanceId,
2240
2260
  objectId,
2261
+ // The submit response often has no workItemId; when the engine created
2262
+ // the next todo, its first item is the authoritative continuation id.
2241
2263
  workItemId: typeof performedData.workItemId === "string" ? performedData.workItemId : null,
2242
2264
  sequenceStatus,
2243
2265
  submittedData: approvalDelta ? projectApprovalReadback(loadedRecord, Object.keys(formData ?? {})) : projectReadbackRecord(loadedRecord, args),
@@ -2290,6 +2312,8 @@ export function registerRuntimeDataLane(tools) {
2290
2312
  }
2291
2313
  if (nextWorkItems.length > 0) {
2292
2314
  readback.nextWorkItems = nextWorkItems;
2315
+ if (readback.workItemId === null)
2316
+ readback.workItemId = nextWorkItems[0].workItemId;
2293
2317
  const nextName = nextWorkItems[0].activityName ?? nextWorkItems[0].activityCode ?? "下一节点";
2294
2318
  if (rejected) {
2295
2319
  readback.returnedTo = {
@@ -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,
@@ -208,11 +211,11 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
208
211
  }),
209
212
  "workitem.list": Object.freeze({
210
213
  scope: "runtime workflow work-item query",
211
- description: "查询当前登录人的待办(`GET /api/api/runtime/workflow/search_workitems`)。WorkItemSearchVO 经 @ModelAttribute 传参,服务端以登录态注入 userId,故无需传处理人——结果天然是「我的待办」。返回每项 workItemId(id) + workflowInstanceId(instanceId) + activityName + state;并尽力经 `GET /api/api/runtime/workflow/getBizObjectByWfInstanceId` 解析 bizObjectId/schemaCode 供 data.submit 审批。`workflowInstanceId` 为客户端精确过滤(服务端 WorkItemSearchVO.filterWorkflowInstanceId 实为 notEqualsTo 排除,不能用于命中实例);带该过滤时 total 为过滤后的条数,原服务端总数在 unfilteredTotal。**想知道「流程停在哪」,用 `workflow.instanceActivity`,不要靠反复 workitem.list**——待办查询只返回在办项,实例停住时永远是空数组。",
214
+ description: "查询当前登录人的待办(`GET /api/api/runtime/workflow/search_workitems`)。WorkItemSearchVO 经 @ModelAttribute 传参,服务端以登录态注入 userId,故无需传处理人——结果天然是「我的待办」。返回每项完整键集:`workItemId, workflowInstanceId, workflowCode, activityCode, activityName, state, sequenceNo, originator, originatorName, participant, participantName, bizObjectId, schemaCode`;外置大结果另带 `itemsCount, ids, itemsPreview, truncated, truncatedKeys, omittedKeys, detailFile/detailSha256`,其中 itemsPreview 为前 3 个 `{id}` 引用。并尽力经 `GET /api/api/runtime/workflow/getBizObjectByWfInstanceId` 解析 bizObjectId/schemaCode 供 data.submit 审批。`workflowInstanceId` 为客户端精确过滤(服务端 WorkItemSearchVO.filterWorkflowInstanceId 实为 notEqualsTo 排除,不能用于命中实例);带该过滤时 total 为过滤后的条数,原服务端总数在 unfilteredTotal。**想知道「流程停在哪」,用 `workflow.instanceActivity`,不要靠反复 workitem.list**——待办查询只返回在办项,实例停住时永远是空数组。",
212
215
  }),
213
216
  "workflow.instanceActivity": Object.freeze({
214
217
  scope: "runtime workflow instance node-state diagnosis",
215
- description: "按实例查全部节点状态(`GET /api/api/runtime/workflow/list_workflow_instance_activity`,WorkflowMaintenanceAssembler.listActivateActivity)。返回模板里每个活动节点的 activityCode/activityName/activityType + status(1=当前激活,0=已完成或未到达)+ 解析出的 participants(含姓名/id)。用于「data.submit 回 PROCESSING 但没有下一待办」的自诊断:例子 `omc call workflow.instanceActivity --args '{\"workflowInstanceId\":\"<id>\"}'`。当没有任何节点 status=1 时,readback 的 diagnosis.code=no-active-activity 并列出排查顺序(参与人为空的审批节点 → 参与人写法 → 容器日志 GetParticipantInfo error)。审批推进正常时该工具不需要——data.submit 的 readback.nextSubmit 已给出下一级参数。",
218
+ description: "按实例查全部节点状态(`activities[].status` 数字枚举:`1=当前激活/持有待办`、`0=已完成或尚未到达`,别与实例级 `instanceStatus:\"PROCESSING\"` 混用——后者只表示流程仍在运行)。返回每个节点 `{activityCode,activityName,activityType,status,participantCount,participants:[{name,id}],sheetTrigger?}`(`GET /api/api/runtime/workflow/list_workflow_instance_activity`,WorkflowMaintenanceAssembler.listActivateActivity)。用于「data.submit 回 PROCESSING 但没有下一待办」的自诊断:例子 `omc call workflow.instanceActivity --args '{\"workflowInstanceId\":\"<id>\"}'`。当没有任何节点 status=1 时,readback 的 diagnosis.code=no-active-activity 并列出排查顺序(参与人为空的审批节点 → 参与人写法 → 容器日志 GetParticipantInfo error)。审批推进正常时该工具不需要——data.submit 的 readback.nextSubmit 已给出下一级参数。",
216
219
  }),
217
220
  }),
218
221
  });
@@ -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`);