@stalfh233/omc-cli 0.6.12 → 0.6.14

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.
Files changed (59) hide show
  1. package/coverage/m1-coverage-manifest-v1.json +2 -0
  2. package/dist/approval-flow.js +84 -3
  3. package/dist/batch-call.js +32 -2
  4. package/dist/bench/call-face-eval.js +2 -2
  5. package/dist/call.js +35 -2
  6. package/dist/capability-overview.js +21 -7
  7. package/dist/cli-contract.js +8 -2
  8. package/dist/cli.js +313 -72
  9. package/dist/describe-cache.js +8 -4
  10. package/dist/discovery.js +4 -0
  11. package/dist/environment-policy.js +20 -0
  12. package/dist/field-families.js +62 -4
  13. package/dist/form-support.js +293 -6
  14. package/dist/goal-routes.js +137 -22
  15. package/dist/identity.js +63 -11
  16. package/dist/lanes/app-contract.js +4 -4
  17. package/dist/lanes/app.js +20 -2
  18. package/dist/lanes/dictionary-contract.js +16 -5
  19. package/dist/lanes/dictionary-coverage.js +1 -0
  20. package/dist/lanes/dictionary.js +200 -8
  21. package/dist/lanes/environment-contract.js +18 -6
  22. package/dist/lanes/environment.js +129 -16
  23. package/dist/lanes/field-contract.js +3 -2
  24. package/dist/lanes/field.js +785 -63
  25. package/dist/lanes/form.js +52 -6
  26. package/dist/lanes/list-view-contract.js +3 -3
  27. package/dist/lanes/list-view.js +143 -12
  28. package/dist/lanes/meta.js +36 -10
  29. package/dist/lanes/model-contract.js +7 -6
  30. package/dist/lanes/model.js +154 -10
  31. package/dist/lanes/organization.js +67 -7
  32. package/dist/lanes/runtime-data-contract.js +4 -2
  33. package/dist/lanes/runtime-data.js +395 -41
  34. package/dist/lanes/workflow-contract.js +2 -2
  35. package/dist/lanes/workflow.js +381 -11
  36. package/dist/model-scaffold.js +11 -3
  37. package/dist/path-projection.js +181 -31
  38. package/dist/read-shapes.js +31 -11
  39. package/dist/recipes/b2-registration.js +1 -1
  40. package/dist/recipes/field.js +16 -3
  41. package/dist/recipes/form.js +4 -3
  42. package/dist/recipes/runtime-data.js +58 -8
  43. package/dist/recipes/workflow.js +86 -1
  44. package/dist/refusal-guidance.js +18 -6
  45. package/dist/rollback-spec.js +41 -2
  46. package/dist/rollback.js +49 -11
  47. package/dist/script.js +72 -12
  48. package/dist/skills.js +91 -3
  49. package/dist/tools.js +113 -1
  50. package/dist/wire.js +13 -1
  51. package/dist/write-lock.js +92 -4
  52. package/dist/write.js +129 -16
  53. package/package.json +3 -3
  54. package/skills/omc-business-configuration.md +1 -1
  55. package/skills/omc-capabilities.md +5 -4
  56. package/skills/omc-playbook.md +41 -0
  57. package/skills/omc-refusals.md +58 -6
  58. package/skills/omc-tool-selection.md +2 -0
  59. package/skills/omc-write-hazards.md +3 -2
@@ -10,9 +10,100 @@ import { defineTool } from "../tool-types.js";
10
10
  import { wireCall, wireRows, WireError } from "../wire.js";
11
11
  import { CHILD_TABLE_PROPERTY_TYPE, diagnoseApprovalSubmitIdentity, runtimeWriteWithAppMapping } from "../runtime-support.js";
12
12
  import { closestMatch } from "../closest-match.js";
13
- import { getReplayToken, searchWorkItems, wireString } from "../workflow-support.js";
13
+ import { getReplayToken, listInstanceActivities, searchWorkItems, wireString } from "../workflow-support.js";
14
+ import { PLATFORM_ADMIN_START_BYPASS, resolvePermissionActor } from "../identity.js";
14
15
  import { ENGINE_OWNED_KEYS, RUNTIME_DATA_LANE_CONTRACT, RUNTIME_DATA_WIRE, runtimeFormDataArg, } from "./runtime-data-contract.js";
15
16
  import { SYSTEM_FIELD_CODES } from "../field-families.js";
17
+ /** r29b F8: keep ids/itemsPreview through governedRead's compact diet. */
18
+ const DATA_LIST_PREVIEW_LIMIT = 20;
19
+ function dataListItemPreview(items) {
20
+ const ids = [];
21
+ const itemsPreview = [];
22
+ for (const item of items) {
23
+ const id = typeof item.id === "string" && item.id.trim() ? item.id : typeof item.fields?.id === "string" ? String(item.fields.id) : null;
24
+ if (!id)
25
+ continue;
26
+ ids.push(id);
27
+ if (itemsPreview.length < DATA_LIST_PREVIEW_LIMIT) {
28
+ const nameRaw = item.fields?.name;
29
+ itemsPreview.push({ id, name: typeof nameRaw === "string" && nameRaw.length > 0 ? nameRaw : null });
30
+ }
31
+ }
32
+ return { ids, itemsPreview };
33
+ }
34
+ /**
35
+ * r29b F8:`data.list` 的大结果被外置后 `items:[]`,调用方连 id 都拿不到
36
+ * (3 行的模型也会这样——于是「没数据」与「被外置」看起来一样)。
37
+ *
38
+ * **只在确实要丢弃 items 时才补 `ids`/`itemsPreview`**:这段只在 governedRead
39
+ * 判定 `large`(>2048B)后才会被调用。早期版本把 ids/preview 无条件塞进 handler
40
+ * 的回执,结果 25 行的小结果因为多了这一份副本被顶过 2048B → 反而触发外置、
41
+ * `items` 变成空数组(composers 的 `--all` 聚齐断言直接失败)。
42
+ *
43
+ * r31b F7:外置后同信封只给一份身份面——`ids` 全量;`itemsPreview` 仅
44
+ * `ids.slice(0,3)` 的引用(`{id}`,不再带 name),避免「同一批行给三份」。
45
+ */
46
+ function dataListCompactProjection(result) {
47
+ const compact = {};
48
+ for (const [key, value] of Object.entries(result)) {
49
+ if (key === "items" && Array.isArray(value)) {
50
+ compact.itemsCount = value.length;
51
+ const preview = dataListItemPreview(value);
52
+ compact.ids = preview.ids;
53
+ // Reference face only — not a second copy of the rows.
54
+ compact.itemsPreview = preview.ids.slice(0, 3).map((id) => ({ id }));
55
+ if (value.length > 0)
56
+ compact.listArtifactHint =
57
+ "items 已外置(truncatedKeys 含 items):完整行见 artifact(detailArtifactId / fetch)。同信封三分法:ids=本页全部 id;itemsPreview=ids 前 3 个的引用(仅 {id},不是第二份行);items=[] 表示正文在 artifact。";
58
+ continue;
59
+ }
60
+ // 这两个键由本函数按 items 现算,handler 不再重复内联。
61
+ if (key === "ids" || key === "itemsPreview" || key === "listArtifactHint")
62
+ continue;
63
+ compact[key] = value;
64
+ }
65
+ return compact;
66
+ }
67
+ /** r29c F1b: runMode is documented but unused — refuse unknown values before wire. */
68
+ const DATA_SUBMIT_RUN_MODE_SUPPORTED = new Set([]);
69
+ function assertSupportedRunMode(runMode) {
70
+ if (runMode === undefined || runMode === null)
71
+ return;
72
+ if (typeof runMode !== "string" || runMode.trim().length === 0) {
73
+ throw new ToolInputError({
74
+ status: "error",
75
+ blocker: "tool-input-invalid",
76
+ tool: "data.submit",
77
+ message: "runMode 必须是字符串;当前平台 REST 提交面**不支持** runMode(传任何值都会 10009 类型转换异常)。该参数不能用来撤回/终止;请省略 runMode。",
78
+ nextCommand: "omc describe data.submit --param runMode",
79
+ });
80
+ }
81
+ if (!DATA_SUBMIT_RUN_MODE_SUPPORTED.has(runMode)) {
82
+ throw new ToolInputError({
83
+ status: "error",
84
+ blocker: "tool-input-invalid",
85
+ tool: "data.submit",
86
+ message: `runMode="${runMode}" 不受支持:当前平台 REST 提交面没有可用的 runMode 取值(传任何值都会 10009 类型转换异常)。` +
87
+ "该参数**不能**用来撤回/终止实例;请省略 runMode。若要清实例,用 data.delete 删业务行(会级联终止)或 model-purge --purge-instances。",
88
+ acceptedValues: { runMode: [] },
89
+ nextCommand: "omc describe data.submit --param runMode",
90
+ });
91
+ }
92
+ }
93
+ /** 1.1.x batch delete is a documented fake-success; gate dry-run + execute alike. */
94
+ function isAiPlatform11Family(session) {
95
+ const version = session.detectedVersion();
96
+ return typeof version === "string" && version.startsWith("1.1");
97
+ }
98
+ function batchDeleteFakeSuccessRefusal(schemaCode, ids) {
99
+ throw new ToolRefusalError({
100
+ status: "refused",
101
+ blocker: "data-delete-batch-fake-success",
102
+ nextCommand: `omc call data.delete --args '${JSON.stringify({ schemaCode, bizObjectId: ids[0] })}'`,
103
+ message: "批量删除(bizObjectIds 多个)在 1.1.25 真机实证为假成功:回执 DELIVERED + absent:true + modelResidue.total 递减,但行仍在(data.list 不变、data.load exists:true);同样的 id 改单条逐个删才真删掉。请用 bizObjectId(单个)逐个删,每条删完用 data.load 确认 exists:false(absent:true 不是删除成功的证明)。" +
104
+ `下一步:把 ${ids.length} 个 id 拆成 ${ids.length} 次单删(示例已写入 nextCommand;其余 id 替换 bizObjectId 即可)。`,
105
+ });
106
+ }
16
107
  // A2(GitLab #15,2026-11-04):单选/字典字段(RADIO/CHECKBOX/DROPDOWN_BOX/
17
108
  // DROPDOWN_MULTI_BOX)在运行面查询按**字典 key**匹配,不按显示值——按「已审核」
18
109
  // 这类显示值过滤会静默 0 条;加 `_key` 后缀的字段名则不在发布元数据里,直接拒。
@@ -681,17 +772,67 @@ export function assertRuntimeFieldValueShapes(meta, formData, childCodes, contex
681
772
  if (problems.length > 0) {
682
773
  throw new ToolRefusalError({
683
774
  status: "refused",
684
- blocker: "submit-child-rows-missing-id",
775
+ // r29b F7: blocker 名与工具无关(data.update / data.submit 共用);旧名保留兼容。
776
+ blocker: "child-rows-missing-id",
777
+ blockerAliases: ["submit-child-rows-missing-id"],
685
778
  schemaCode: context.schemaCode,
686
779
  childTables: problems,
687
780
  message: `更新既有记录时子表行必须带 id:${problems.join("、")}。平台对无 id 的子行按**新增插入**(不判重)——` +
688
781
  `r17 真机:同一条记录连发 4 次带 2 行无 id 子表的 update,子行从 2 变 8,聚合 SUM 翻倍。` +
689
782
  `改既有行:先 data.load 拿子行 id 原样带回(平台走 rowStatus=Modified);` +
690
- `有意追加新行:显式给该行 rowStatus:"Added"。`,
783
+ `有意追加新行:显式给该行 rowStatus:"Added";删除行:只传 {id, rowStatus:"Deleted"}(少传行不会删)。` +
784
+ `三合一示例见 describe data.update --example child-rows-upsert。`,
691
785
  });
692
786
  }
693
787
  }
694
788
  }
789
+ /**
790
+ * r29b F6: 「少传行」被平台静默忽略。比较提交行与库内行,缺 Deleted 标记时给 warning,
791
+ * **绝不**自动删(语义不能猜)。
792
+ */
793
+ function childRowsNotDeletedWarnings(formData, existingRecord, childCodes) {
794
+ if (!existingRecord)
795
+ return [];
796
+ const warnings = [];
797
+ for (const code of childCodes) {
798
+ const submitted = formData[code];
799
+ if (!Array.isArray(submitted))
800
+ continue;
801
+ const existing = existingRecord[code];
802
+ if (!Array.isArray(existing))
803
+ continue;
804
+ const existingIds = existing
805
+ .map((row) => (row && typeof row === "object" && !Array.isArray(row) ? String(row.id ?? "").trim() : ""))
806
+ .filter((id) => id.length > 0);
807
+ if (existingIds.length === 0)
808
+ continue;
809
+ const submittedIds = new Set(submitted
810
+ .map((row) => (row && typeof row === "object" && !Array.isArray(row) ? String(row.id ?? "").trim() : ""))
811
+ .filter((id) => id.length > 0));
812
+ const hasExplicitDelete = submitted.some((row) => row &&
813
+ typeof row === "object" &&
814
+ !Array.isArray(row) &&
815
+ typeof row.rowStatus === "string" &&
816
+ String(row.rowStatus).toLowerCase() === "deleted");
817
+ if (hasExplicitDelete)
818
+ continue;
819
+ if (submitted.length >= existingIds.length && submittedIds.size >= existingIds.length)
820
+ continue;
821
+ const omitted = existingIds.filter((id) => !submittedIds.has(id));
822
+ if (omitted.length === 0 && submitted.length >= existingIds.length)
823
+ continue;
824
+ const missing = omitted.length > 0 ? omitted : existingIds.slice(submitted.length);
825
+ if (missing.length === 0)
826
+ continue;
827
+ warnings.push({
828
+ code: "child-rows-not-deleted",
829
+ childTable: code,
830
+ rows: missing,
831
+ hint: "少传行不会删除;删除需显式 rowStatus:'Deleted'(只给 id)",
832
+ });
833
+ }
834
+ return warnings;
835
+ }
695
836
  /**
696
837
  * r18 (2026-10-26 live): `errcode 40000 调用数据库发生异常` carries NO detail and
697
838
  * `omc-refusals` had nothing for it. A probe burned ~40 calls bisecting it:
@@ -776,8 +917,10 @@ export function registerRuntimeDataLane(tools) {
776
917
  filters: z.array(z.record(z.string(), z.unknown())).optional().describe("clauses combined with AND; a malformed clause is refused with blocker invalid-filter-DSL"),
777
918
  filterGroup: z.record(z.string(), z.unknown()).optional().describe('nested group { op:"and"|"or", clauses:[clause|group] }'),
778
919
  rawCondition: z.unknown().optional(),
779
- displayFields: z.array(z.string().min(1)).optional(),
780
- columns: z.array(z.string().min(1)).optional(),
920
+ displayFields: z.array(z.string().min(1)).optional().describe("列投影(平台 query/list 的 displayFields);与 columns / fields 同义"),
921
+ columns: z.array(z.string().min(1)).optional().describe("列投影别名(=displayFields)"),
922
+ // r29b F9: data.load/save/update 都叫 fields;list 面原先只有 displayFields/columns。
923
+ fields: z.array(z.string().min(1)).optional().describe("列投影别名(=displayFields;与 data.load/data.save/data.update 的 fields 同语义)"),
781
924
  orders: z.array(z.object({ field: z.string().min(1), dir: z.enum(["ASC", "DESC"]) })).optional(),
782
925
  mobile: z.boolean().optional(),
783
926
  showTotal: z.boolean().default(true),
@@ -916,7 +1059,8 @@ export function registerRuntimeDataLane(tools) {
916
1059
  nextCommand: `omc call data.load --env <env> --args '{"schemaCode":"${args.schemaCode}","bizObjectId":"<主表 bizObjectId>"}'`,
917
1060
  };
918
1061
  }
919
- const requestedFields = args.displayFields ?? args.columns;
1062
+ // r29b F9: fields 是 displayFields/columns 的同义别名(与 data.load 对齐)。
1063
+ const requestedFields = args.displayFields ?? args.columns ?? args.fields;
920
1064
  const fields = requestedFields && requestedFields.length > 0 ? requestedFields : undefined;
921
1065
  const body = {
922
1066
  schemaCode: args.schemaCode,
@@ -992,6 +1136,7 @@ export function registerRuntimeDataLane(tools) {
992
1136
  // 元数据读失败不阻断数据返回
993
1137
  }
994
1138
  }
1139
+ const preview = dataListItemPreview(items);
995
1140
  return {
996
1141
  schemaCode: args.schemaCode,
997
1142
  total,
@@ -1009,6 +1154,8 @@ export function registerRuntimeDataLane(tools) {
1009
1154
  // 形状常量:脚本按 result.items 取数(与内联态一致),itemsCount 与
1010
1155
  // total 的差就是被截断的条数——不再只靠 size 反推。
1011
1156
  itemsCount: collected.length,
1157
+ // r29b F8 的 ids/itemsPreview 由 compact(外置时)现算,不在这里内联——
1158
+ // 内联会让小结果越过 2048B 外置阈值,反而把 items 挤成 []。
1012
1159
  ...(truncatedByCap
1013
1160
  ? {
1014
1161
  truncated: true,
@@ -1078,11 +1225,36 @@ export function registerRuntimeDataLane(tools) {
1078
1225
  // present, zero non-null business values) and say so.
1079
1226
  const businessKeys = fields ?? Object.keys(items[0]?.fields ?? {}).filter((field) => !["id", "bizObjectId", "schemaCode", "sequenceStatus", "createdTime", "updatedTime", "creator", "updater", "name"].includes(field));
1080
1227
  const projectionEmpty = items.length > 0 && businessKeys.length > 0 && items.every((item) => businessKeys.every((key) => item.fields[key] === null || item.fields[key] === undefined));
1228
+ const preview = dataListItemPreview(items);
1229
+ const requestedPage = args.page ?? 0;
1230
+ // r31b F9: an out-of-range page can answer total:0 / totalPages:1 (platform),
1231
+ // which scripts misread as "model empty". When the page looks empty-and-
1232
+ // beyond, re-read page 0 for the authoritative total and mark pageOutOfRange.
1233
+ let total = args.showTotal === false ? null : page.total;
1234
+ let totalPages = page.totalPages;
1235
+ let pageOutOfRange = false;
1236
+ if (args.showTotal !== false
1237
+ && items.length === 0
1238
+ && requestedPage > 0
1239
+ && Number(page.total ?? 0) === 0) {
1240
+ const probe = await wireCall(session, {
1241
+ path: ENDPOINTS.runtimeQueryList,
1242
+ method: "POST",
1243
+ body: { ...body, page: 0, size: 1, showTotal: true },
1244
+ });
1245
+ const probePage = extractBizObjectPage(probe.data);
1246
+ total = probePage.total;
1247
+ totalPages = probePage.totalPages;
1248
+ pageOutOfRange = Number(probePage.total ?? 0) > 0 || (probePage.totalPages > 0 && requestedPage >= probePage.totalPages);
1249
+ }
1250
+ else if (totalPages > 0 && requestedPage >= totalPages) {
1251
+ pageOutOfRange = true;
1252
+ }
1081
1253
  const readback = {
1082
1254
  schemaCode: args.schemaCode,
1083
1255
  ...(projectionEmpty ? { projectionWarning: { reason: "business-columns-null", hint: "模型可能未配置列表视图:data.list 只投影列表列,业务列会全为 null;核对写入结果请用 data.load(或先建列表视图)" } } : {}),
1084
1256
  ...(args.queryCode !== undefined ? { queryCode: args.queryCode } : {}),
1085
- total: args.showTotal === false ? null : page.total,
1257
+ total,
1086
1258
  // This branch is the non-`all` read. `excludedDraft` used to be
1087
1259
  // unconditional, but r26 真机实证(2026-11-05):**带查询条件时**草稿行
1088
1260
  // 被排除(total 也只计非草稿),**不带条件时**同一个 data.save 的 DRAFT
@@ -1106,10 +1278,12 @@ export function registerRuntimeDataLane(tools) {
1106
1278
  : { draftPolicy: "unspecified", hint: "本次结果无 DRAFT 行,但本面是否过滤草稿不能据此断定;判定状态读 data.load 的 sequenceStatus" };
1107
1279
  return { filter: policy };
1108
1280
  })(),
1109
- ...(choiceFilterFields.length > 0 && Number(page.total ?? 0) === 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}),
1110
- totalPages: page.totalPages,
1111
- page: page.page ?? args.page ?? 0,
1281
+ ...(choiceFilterFields.length > 0 && Number(total ?? 0) === 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}),
1282
+ totalPages,
1283
+ page: page.page ?? requestedPage,
1112
1284
  size: page.size ?? args.size ?? 20,
1285
+ ...(pageOutOfRange ? { pageOutOfRange: true, emptyPage: true } : {}),
1286
+ // r29b F8 的 ids/itemsPreview 由 compact(外置时)现算,见 dataListCompactProjection。
1113
1287
  items,
1114
1288
  };
1115
1289
  // A model's unconfigured default list view projects business columns as
@@ -1165,8 +1339,15 @@ export function registerRuntimeDataLane(tools) {
1165
1339
  if (hint)
1166
1340
  readback.projectionHint = hint;
1167
1341
  }
1342
+ // r29b F8: ids/itemsPreview 已在同信封;artifact 提示由 compact 外置后的
1343
+ // fetch/detailArtifactId 承担。这里不改既有 projectionHint 主句(契约测试
1344
+ // 钉死 RUNTIME_DATA_WIRE.projectionHint 原文)。
1168
1345
  return readback;
1169
- }, { full: args.full === true, target: `data.list:${JSON.stringify({ schemaCode: args.schemaCode, queryCode: args.queryCode ?? args.schemaCode, filter: args.filter, filters: args.filters, filterGroup: args.filterGroup, rawCondition: args.rawCondition, displayFields: args.displayFields, columns: args.columns, page: args.page ?? 0, size: args.size ?? 10 })}` });
1346
+ }, {
1347
+ full: args.full === true,
1348
+ compact: dataListCompactProjection,
1349
+ target: `data.list:${JSON.stringify({ schemaCode: args.schemaCode, queryCode: args.queryCode ?? args.schemaCode, filter: args.filter, filters: args.filters, filterGroup: args.filterGroup, rawCondition: args.rawCondition, displayFields: args.displayFields ?? args.columns ?? args.fields, columns: args.columns, page: args.page ?? 0, size: args.size ?? 10 })}`,
1350
+ });
1170
1351
  },
1171
1352
  }));
1172
1353
  tools.push(defineTool({
@@ -1248,7 +1429,7 @@ export function registerRuntimeDataLane(tools) {
1248
1429
  inputSchema: {
1249
1430
  schemaCode: z.string().min(1),
1250
1431
  sheetCode: z.string().optional(),
1251
- formData: runtimeFormDataArg,
1432
+ formData: runtimeFormDataArg.describe('business-object data keyed by field code(关联/人员形状同默认描述)。mappings 仅门户回填;字典-backed 不校验取值(不存在/已停用码也会落库,要拦请配数据规则)。'),
1252
1433
  workflowCode: z.string().optional().describe("portal draft-save shape: create the row AND a DRAFT workflow instance + start workItem (finishStart=FALSE); submit later via data.submit with the returned workItemId. formData must NOT carry an id — in-place workflow start has no REST surface in 8.6.26"),
1253
1434
  submit: z.boolean().optional().describe("true=保存后立即直提(无流程模型 DRAFT→COMPLETED,触发 Create 规则);与 workflowCode 互斥。**按业务约定正式数据优先直接 data.submit(无 id=新建并提交一调完成)——本参数是便捷别名,save 的定位是草稿/暂存**"),
1254
1435
  allowStaffOnSubmit: z.boolean().optional().describe("submit:true 时显式允许携带人员字段(issue#10:人员字段提交面两种形状都可能 50000,可靠链路是 save 落库后提交时省略;个别平台构建接受提交面携带,自担风险)"),
@@ -1485,7 +1666,11 @@ export function registerRuntimeDataLane(tools) {
1485
1666
  message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
1486
1667
  });
1487
1668
  }
1488
- const formData = (await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true })).formData;
1669
+ const prepared = await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true });
1670
+ const formData = prepared.formData;
1671
+ const existingRecord = normalizeLoadedRecord(existing);
1672
+ // r29b F6: 少传行 ≠ 删除。写前比一次,缺 Deleted 时只给 warning。
1673
+ const omitWarnings = childRowsNotDeletedWarnings(formData, existingRecord ?? undefined, prepared.childCodes);
1489
1674
  let performed;
1490
1675
  try {
1491
1676
  performed = await wireCall(session, {
@@ -1512,7 +1697,12 @@ export function registerRuntimeDataLane(tools) {
1512
1697
  // Flat record contract (ticket 17): live FormLoadVO normalizes to the
1513
1698
  // fake's flat field shape so `authoritative.<fieldCode>` reads work on
1514
1699
  // both faces.
1515
- readback: { authoritative: projectReadbackRecord(updatedRecord, args), sequenceStatus, ...(updatedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(updatedEmptyFields, "data.update") } : {}) },
1700
+ readback: {
1701
+ authoritative: projectReadbackRecord(updatedRecord, args),
1702
+ sequenceStatus,
1703
+ ...(updatedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(updatedEmptyFields, "data.update") } : {}),
1704
+ ...(omitWarnings.length > 0 ? { warnings: omitWarnings } : {}),
1705
+ },
1516
1706
  };
1517
1707
  },
1518
1708
  });
@@ -1546,6 +1736,30 @@ export function registerRuntimeDataLane(tools) {
1546
1736
  scope: RUNTIME_TOOLS["data.delete"].scope,
1547
1737
  description: RUNTIME_TOOLS["data.delete"].description,
1548
1738
  inputSchema: { schemaCode: z.string().min(1), sheetCode: z.string().optional(), bizObjectId: z.string().min(1).optional(), bizObjectIds: z.array(z.string().min(1)).optional().describe("批量删除多个业务对象 id(等价于逐个 data.delete,但只花一次往返;返回 deleted 列表)") },
1739
+ // r29b F10: dry-run 也必须拦 1.1.x 批量假成功,否则「计划能过、执行不能过」。
1740
+ dryRunPayload: async (session, args) => {
1741
+ const ids = args.bizObjectIds && args.bizObjectIds.length > 0 ? args.bizObjectIds : args.bizObjectId ? [args.bizObjectId] : [];
1742
+ if (ids.length > 1 && isAiPlatform11Family(session)) {
1743
+ return {
1744
+ wouldChange: false,
1745
+ blocker: "data-delete-batch-fake-success",
1746
+ plan: {
1747
+ warnings: [
1748
+ {
1749
+ code: "data-delete-batch-fake-success",
1750
+ message: `1.1.x(检测到 ${session.detectedVersion()})上 bizObjectIds 批量删除是假成功;请逐个单删`,
1751
+ },
1752
+ ],
1753
+ },
1754
+ nextCommand: `omc call data.delete --args '${JSON.stringify({ schemaCode: args.schemaCode, bizObjectId: ids[0] })}'`,
1755
+ message: "dry-run 已拦截:批量删除(bizObjectIds 多个)在 1.1.x 真机实证为假成功。请用 bizObjectId(单个)逐个删。",
1756
+ };
1757
+ }
1758
+ return {
1759
+ wouldChange: ids.length > 0,
1760
+ summary: ids.length > 1 ? `即将删除 ${ids.length} 条` : ids.length === 1 ? `即将删除 1 条(${ids[0]})` : "缺少 bizObjectId/bizObjectIds",
1761
+ };
1762
+ },
1549
1763
  execute: async (session, args) => {
1550
1764
  const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1551
1765
  const ids = args.bizObjectIds && args.bizObjectIds.length > 0 ? args.bizObjectIds : args.bizObjectId ? [args.bizObjectId] : [];
@@ -1565,23 +1779,19 @@ export function registerRuntimeDataLane(tools) {
1565
1779
  // DELIVERED + absent:true + a DECREASING modelResidue.total while the
1566
1780
  // rows are still queryable (data.list unchanged, data.load exists:true).
1567
1781
  // Re-issuing the same ids one at a time deleted them for real. Refuse
1568
- // the batch shape and teach the per-id loop until the platform path is
1569
- // understood; single-id deletes go through the exact same deleteOne.
1782
+ // the batch shape on 1.1.x (and keep refusing unconditionally for the
1783
+ // documented fake-success until an 8.6 path is proven safe).
1570
1784
  if (ids.length > 1)
1571
- throw new ToolRefusalError({
1572
- status: "refused",
1573
- blocker: "data-delete-batch-fake-success",
1574
- // r28b F14: point at a concrete single-id call, not the methodology page.
1575
- nextCommand: `omc call data.delete --args '${JSON.stringify({ schemaCode: args.schemaCode, bizObjectId: ids[0] })}'`,
1576
- message: "批量删除(bizObjectIds 多个)在 1.1.25 真机实证为假成功:回执 DELIVERED + absent:true + modelResidue.total 递减,但行仍在(data.list 不变、data.load exists:true);同样的 id 改单条逐个删才真删掉。请用 bizObjectId(单个)逐个删,每条删完用 data.load 确认 exists:false(absent:true 不是删除成功的证明)。" +
1577
- `下一步:把 ${ids.length} 个 id 拆成 ${ids.length} 次单删(示例已写入 nextCommand;其余 id 替换 bizObjectId 即可)。`,
1578
- });
1785
+ batchDeleteFakeSuccessRefusal(args.schemaCode, ids);
1579
1786
  const deleteOne = async (bizObjectId) => {
1580
1787
  // Delete is destructive: probe the authoritative object first so an
1581
1788
  // absent id is an idempotent no-op, never a blind runtime delete.
1582
1789
  const before = await loadExistingBizObject(session, args.schemaCode, sheetCode, bizObjectId);
1790
+ // r31b F12: mirror model.delete — noop/deleted booleans so concurrent
1791
+ // losers are not mistaken for successful deletes (both used to look like
1792
+ // status:ok + DELIVERED + absent:true).
1583
1793
  if (!before)
1584
- return { bizObjectId, skipped: "already-absent", absent: true };
1794
+ return { bizObjectId, skipped: "already-absent", absent: true, noop: true, deleted: false };
1585
1795
  const performed = await wireCall(session, {
1586
1796
  path: ENDPOINTS.runtimeFormDelete,
1587
1797
  method: "POST",
@@ -1602,13 +1812,26 @@ export function registerRuntimeDataLane(tools) {
1602
1812
  else
1603
1813
  throw error;
1604
1814
  }
1605
- return { bizObjectId, errcode: performed.errcode, absent };
1815
+ return { bizObjectId, errcode: performed.errcode, absent, deleted: absent === true, noop: false };
1606
1816
  };
1607
1817
  if (ids.length === 1) {
1608
1818
  const one = await deleteOne(ids[0]);
1609
- if (one.skipped === "already-absent")
1610
- return { performed: { skipped: "already-absent", sheetCode, resolvedFrom }, readback: { absent: true, recoveryHint: "id 不存在(already-absent≠删除成功):真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏,shell 空值兜底出来的 id 同样删不掉任何东西。若 modelResidue.total>0,用 items[].id 重删。", ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1611
- return { performed: { errcode: one.errcode, sheetCode, resolvedFrom }, readback: { absent: one.absent, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1819
+ if (one.skipped === "already-absent") {
1820
+ return {
1821
+ performed: { skipped: "already-absent", noop: true, deleted: false, sheetCode, resolvedFrom },
1822
+ readback: {
1823
+ absent: true,
1824
+ deleted: false,
1825
+ 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)),
1828
+ },
1829
+ };
1830
+ }
1831
+ return {
1832
+ 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)) },
1834
+ };
1612
1835
  }
1613
1836
  const deleted = [];
1614
1837
  for (const id of ids)
@@ -1636,11 +1859,19 @@ export function registerRuntimeDataLane(tools) {
1636
1859
  allowChildRowAppend: z.boolean().optional().describe("默认 false:审批提交(带 workItemId/workflowInstanceId)时 formData 含子表数组会被拒——平台按追加插行,每次提交重复一份。确实要追加子行时才置 true。"),
1637
1860
  allowStaffOnSubmit: z.boolean().optional().describe("默认 false:提交携带人员字段会被拒(issue#10:两种形状都可能 50000;可靠链路=save 落库后提交省略人员字段)。个别平台构建接受提交面携带时置 true 自担。"),
1638
1861
  approval: z.record(z.string(), z.unknown()).optional(),
1639
- runMode: z.string().optional(),
1862
+ runMode: z
1863
+ .string()
1864
+ .optional()
1865
+ .describe("平台 REST 提交面当前**不支持** runMode(传任何值会 10009 类型转换异常)。**不能**用来撤回/终止;请省略。已支持取值:无。清实例用 data.delete 或 model-purge --purge-instances。"),
1640
1866
  isSimulative: z.boolean().optional(),
1641
1867
  ...readbackProjectionArgs,
1642
1868
  },
1869
+ preflight: (args) => {
1870
+ // r29c F1b: dry-run 与真写同门——不认识的 runMode 写前拒。
1871
+ assertSupportedRunMode(args.runMode);
1872
+ },
1643
1873
  execute: async (session, args) => {
1874
+ assertSupportedRunMode(args.runMode);
1644
1875
  // r13 #5: an EMPTY STRING was forwarded verbatim, so the platform answered
1645
1876
  // 100002「参数为空」with no hint which parameter. Absent and blank are not
1646
1877
  // the same intent: refuse the blank one by name, zero wire.
@@ -2020,6 +2251,10 @@ export function registerRuntimeDataLane(tools) {
2020
2251
  // response carried only the object id. Attach the pending work items for
2021
2252
  // this instance while the flow is still in flight (best-effort: a lookup
2022
2253
  // failure must never fail the submit).
2254
+ // r29c F12: 驳回/通过要能从回执区分——agree:false → decision:REJECT。
2255
+ const rejected = hasWorkItem && args.agree === false;
2256
+ if (hasWorkItem)
2257
+ readback.decision = rejected ? "REJECT" : "APPROVE";
2023
2258
  if (hasWorkflow && effectiveInstanceId && sequenceStatus !== "COMPLETED") {
2024
2259
  try {
2025
2260
  const pending = await searchWorkItems(session, { instanceId: effectiveInstanceId });
@@ -2029,18 +2264,82 @@ export function registerRuntimeDataLane(tools) {
2029
2264
  activityName: wireString(item.activityName),
2030
2265
  state: wireString(item.state),
2031
2266
  }));
2267
+ // r29c F5: searchWorkItems 只回**当前登录人**待办;下一节点审批人不是
2268
+ // 你时 pending 为空 ≠ 没生成待办。用 listInstanceActivities 区分。
2269
+ let activeActivities = [];
2270
+ if (nextWorkItems.length === 0 && sequenceStatus === "PROCESSING") {
2271
+ try {
2272
+ const rows = await listInstanceActivities(session, effectiveInstanceId);
2273
+ activeActivities = rows
2274
+ .filter((row) => row.status === 1 || row.status === "1")
2275
+ .map((row) => {
2276
+ const participants = Array.isArray(row.participantors) ? row.participantors : [];
2277
+ return {
2278
+ activityCode: wireString(row.activityCode),
2279
+ activityName: wireString(row.activityName),
2280
+ participants: participants.slice(0, 10).map((person) => ({
2281
+ name: wireString(person.name) ?? wireString(person.userName) ?? null,
2282
+ id: wireString(person.id) ?? wireString(person.userId) ?? null,
2283
+ })),
2284
+ };
2285
+ });
2286
+ }
2287
+ catch {
2288
+ // best-effort
2289
+ }
2290
+ }
2032
2291
  if (nextWorkItems.length > 0) {
2033
2292
  readback.nextWorkItems = nextWorkItems;
2034
- // The submit RESPONSE's own workItemId is null (it names the item
2035
- // just completed, not the next one). Expose the fully-formed args
2036
- // for the NEXT approval so a multi-level flow advances without an
2037
- // extra workitem.list round or guessing which id to use.
2038
- readback.nextSubmit = {
2039
- schemaCode: args.schemaCode,
2040
- workItemId: nextWorkItems[0].workItemId,
2041
- workflowInstanceId: effectiveInstanceId,
2042
- bizObjectId: objectId,
2043
- note: "下一级审批:用这些字段调 data.submit(同意传 agree:true / formData 回填)。",
2293
+ const nextName = nextWorkItems[0].activityName ?? nextWorkItems[0].activityCode ?? "下一节点";
2294
+ if (rejected) {
2295
+ readback.returnedTo = {
2296
+ activityCode: nextWorkItems[0].activityCode,
2297
+ activityName: nextWorkItems[0].activityName,
2298
+ };
2299
+ readback.nextSubmit = {
2300
+ schemaCode: args.schemaCode,
2301
+ workItemId: nextWorkItems[0].workItemId,
2302
+ workflowInstanceId: effectiveInstanceId,
2303
+ bizObjectId: objectId,
2304
+ note: `已退回 ${nextName}:用这些字段调 data.submit 继续处理。`,
2305
+ };
2306
+ }
2307
+ else {
2308
+ readback.nextSubmit = {
2309
+ schemaCode: args.schemaCode,
2310
+ workItemId: nextWorkItems[0].workItemId,
2311
+ workflowInstanceId: effectiveInstanceId,
2312
+ bizObjectId: objectId,
2313
+ note: "下一级审批:用这些字段调 data.submit(同意传 agree:true / formData 回填)。",
2314
+ };
2315
+ }
2316
+ }
2317
+ else if (sequenceStatus === "PROCESSING" && activeActivities.length > 0) {
2318
+ const first = activeActivities[0];
2319
+ const holderNames = first.participants
2320
+ .map((person) => person.name ?? person.id)
2321
+ .filter((value) => typeof value === "string" && value.length > 0);
2322
+ const holders = holderNames.length > 0 ? holderNames.join("、") : "(参与人未回显)";
2323
+ if (rejected) {
2324
+ readback.returnedTo = { activityCode: first.activityCode, activityName: first.activityName };
2325
+ readback.nextSubmit = {
2326
+ schemaCode: args.schemaCode,
2327
+ workflowInstanceId: effectiveInstanceId,
2328
+ bizObjectId: objectId,
2329
+ note: `已退回 ${first.activityName ?? first.activityCode ?? "上一节点"}(当前登录人看不到该待办;持有人:${holders})。`,
2330
+ };
2331
+ }
2332
+ readback.flowWarning = {
2333
+ code: "flow-next-workitem-not-yours",
2334
+ message: `提交成功,下一节点已激活:${first.activityName ?? first.activityCode ?? "?"},但待办不在当前登录人名下(持有人:${holders})。这不是「没生成待办」。`,
2335
+ activityCode: first.activityCode,
2336
+ activityName: first.activityName,
2337
+ participants: first.participants,
2338
+ checks: [
2339
+ `omc call workflow.instanceActivity --args '{"workflowInstanceId":"${effectiveInstanceId}"}'`,
2340
+ `omc call workitem.list --args '{"workflowInstanceId":"${effectiveInstanceId}","participant":"<持有人 id>"}'`,
2341
+ "要用该参与人账号另建环境(omc env add)再 data.submit,或临时把节点参与人改成发起人",
2342
+ ],
2044
2343
  };
2045
2344
  }
2046
2345
  else if (sequenceStatus === "PROCESSING") {
@@ -2050,9 +2349,11 @@ export function registerRuntimeDataLane(tools) {
2050
2349
  // create NO next work item while the submit still answers errCode 0.
2051
2350
  // Without this line the caller cannot tell "flow advanced" from
2052
2351
  // "flow silently stalled" — surface it instead of staying quiet.
2352
+ // 仅 activeCount=0 才保留 flow-no-next-workitem(勿再说「参与人解析为空」
2353
+ // 当其实有 active 节点——那是 F5 误报根因)。
2053
2354
  readback.flowWarning = {
2054
2355
  code: "flow-no-next-workitem",
2055
- message: "提交成功但该实例没有生成下一节点待办,流程停在 PROCESSING。常见原因:审批参与人解析为空(角色无成员 / 成员未挂发起人部门 / 参与人函数未注册)。",
2356
+ message: "提交成功但该实例没有任何激活节点(activeCount=0),流程停在 PROCESSING。常见原因:审批参与人解析失败导致未创建下一节点,或引擎异常。",
2056
2357
  checks: [
2057
2358
  `omc call workflow.instanceActivity --args '{"workflowInstanceId":"${effectiveInstanceId}"}'(看每个节点的 status 与 participants;无 status=1 即停住)`,
2058
2359
  "容器日志搜 GetParticipantInfo error / CustomParticipleMethod{...} -> []",
@@ -2066,6 +2367,49 @@ export function registerRuntimeDataLane(tools) {
2066
2367
  }
2067
2368
  }
2068
2369
  }
2370
+ // r29c F10: 仅审批提交(有 workItemId)才输出 approvalNote;发起提交改成「已发起」。
2371
+ const startNote = !hasWorkItem && hasWorkflow && typeof readback.workflowInstanceId === "string"
2372
+ ? (() => {
2373
+ const nextName = Array.isArray(readback.nextWorkItems) && readback.nextWorkItems.length > 0
2374
+ ? String(readback.nextWorkItems[0].activityName ?? readback.nextWorkItems[0].activityCode ?? "下一节点")
2375
+ : readback.flowWarning && typeof readback.flowWarning.activityName === "string"
2376
+ ? String(readback.flowWarning.activityName)
2377
+ : null;
2378
+ return nextName ? `已发起,等待 ${nextName} 审批` : "已发起,等待下一节点审批";
2379
+ })()
2380
+ : undefined;
2381
+ // r30d F5/F7: name the operator + whether this start succeeded only because
2382
+ // a platform admin bypassed the start-permission whitelist. Without this,
2383
+ // "config didn't take effect" and "admin bypass" are indistinguishable.
2384
+ let actor = await resolvePermissionActor(session);
2385
+ let permissionBypass;
2386
+ const startedWorkflow = !hasWorkItem && (autoStarted || hasWorkflowCode) && typeof (readback.workflowInstanceId ?? explicitWorkflowCode) === "string";
2387
+ if (startedWorkflow && actor.isAdmin) {
2388
+ let onWhitelist = false;
2389
+ const code = typeof explicitWorkflowCode === "string" && explicitWorkflowCode ? explicitWorkflowCode : null;
2390
+ if (code) {
2391
+ try {
2392
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.workflowListPermission, query: { workflowCode: code } })).data);
2393
+ // list_permission never echoes unitId; best-effort match by name/code
2394
+ // against the actor, or by unitType=3 + display name.
2395
+ onWhitelist = rows.some((row) => {
2396
+ const unitType = String(row.unitType ?? "");
2397
+ if (unitType === "3" && actor.userId && (String(row.id ?? "") === actor.userId || String(row.code ?? "") === actor.userId))
2398
+ return true;
2399
+ if (actor.name && String(row.name ?? "") === actor.name)
2400
+ return true;
2401
+ return false;
2402
+ });
2403
+ }
2404
+ catch {
2405
+ onWhitelist = false;
2406
+ }
2407
+ }
2408
+ if (!onWhitelist) {
2409
+ permissionBypass = PLATFORM_ADMIN_START_BYPASS;
2410
+ actor = { ...actor, bypassReason: PLATFORM_ADMIN_START_BYPASS };
2411
+ }
2412
+ }
2069
2413
  return {
2070
2414
  performed: {
2071
2415
  errcode: performed.errcode,
@@ -2081,9 +2425,17 @@ export function registerRuntimeDataLane(tools) {
2081
2425
  // saved as a bare sheet row — surfaced so the caller knows the row is
2082
2426
  // now a PROCESSING workflow instance, not an orphan sheet record.
2083
2427
  ...(autoStarted ? { autoStartedWorkflow: explicitWorkflowCode } : {}),
2428
+ ...(permissionBypass ? { permissionBypass } : {}),
2084
2429
  },
2085
2430
  readback: {
2086
2431
  ...readback,
2432
+ actor,
2433
+ ...(permissionBypass
2434
+ ? {
2435
+ permissionBypass,
2436
+ permissionNote: "本次发起成功是因为平台管理员绕过了发起权限白名单(h_perm_admin ADMIN/SYS_MNG),不证明白名单对普通用户生效。要用非管理员账号另建环境验证。",
2437
+ }
2438
+ : {}),
2087
2439
  // Portal-required semantics are NOT enforced by the REST submit path;
2088
2440
  // say exactly what the tool checked so `ok` is never mistaken for
2089
2441
  // "the portal would have accepted this" (dogfood R3). This is a
@@ -2094,11 +2446,13 @@ export function registerRuntimeDataLane(tools) {
2094
2446
  // the platform (`agree: args.agree !== false`). That default is right
2095
2447
  // for the portal button, but for an API caller "I forgot the flag" and
2096
2448
  // "I approve" must not look identical in the receipt.
2449
+ // r29c F10: 只在审批提交(有 workItemId)时给 approvalNote;发起提交用 startNote。
2097
2450
  ...(hasWorkItem && args.agree === undefined
2098
2451
  ? {
2099
2452
  approvalNote: "本次是审批提交但未传 agree——平台按**通过**处理(顶层 agree 缺省 true)。要驳回必须显式 agree:false;approval.agree 不生效。",
2100
2453
  }
2101
2454
  : {}),
2455
+ ...(startNote ? { startNote } : {}),
2102
2456
  portalRulesExecuted: false,
2103
2457
  portalRulesNote: "portalRulesExecuted 只指门户客户端的必填/控件校验未在 REST 提交时执行;服务端业务规则(含流程 end 规则)照常运行。",
2104
2458
  requiredFields: validation.requiredFields,