@stalfh233/omc-cli 0.6.17 → 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) {
@@ -371,6 +372,106 @@ function readbackWarningFor(emptyFields, toolName) {
371
372
  return undefined;
372
373
  return `${toolName} 已 DELIVERED,但即时读回里这些本次提交的字段为空/缺省:${emptyFields.join("、")}。平台 form/load 在写事务后可能先回系统字段、业务字段滞后(真机 1.1.25,r14 见过只回 modifiedTime 的一笔)。**先 omc call data.load 核对,不要重写**;若 data.load 仍为空,才按投影/字段 code 排查。`;
373
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
+ }
374
475
  function projectApprovalReadback(record, submittedKeys) {
375
476
  if (record === null || typeof record !== "object" || Array.isArray(record))
376
477
  return {};
@@ -979,6 +1080,8 @@ export function registerRuntimeDataLane(tools) {
979
1080
  throw error;
980
1081
  }
981
1082
  return governedRead(ctx, self, args, async (session) => {
1083
+ const applicationScope = await resolveApplicationScope(session);
1084
+ const application = { applicationCode: applicationScope.code, applicationResolution: applicationResolutionOf(applicationScope) };
982
1085
  // A2:DSL 过滤命中的单选/字典字段(total:0 时给值写法提示)
983
1086
  let choiceFilterFields = [];
984
1087
  // Published field metadata is the sole source for filter propertyType
@@ -1103,7 +1206,7 @@ export function registerRuntimeDataLane(tools) {
1103
1206
  const firstPage = extractBizObjectPage(first.data);
1104
1207
  const total = Number(firstPage.total ?? 0);
1105
1208
  if (total === 0 || firstPage.content.length === 0)
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) } : {}) };
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) } : {}) };
1107
1210
  const rows = [...firstPage.content];
1108
1211
  const target = Math.min(total, CAP);
1109
1212
  let pageIndex = 1;
@@ -1148,6 +1251,7 @@ export function registerRuntimeDataLane(tools) {
1148
1251
  const preview = dataListItemPreview(items);
1149
1252
  return {
1150
1253
  schemaCode: args.schemaCode,
1254
+ ...application,
1151
1255
  total,
1152
1256
  page: "all",
1153
1257
  size: collected.length,
@@ -1155,7 +1259,11 @@ export function registerRuntimeDataLane(tools) {
1155
1259
  // 与普通读同一个口径(r28b 真机):草稿策略必须**从结果集实际统计**,
1156
1260
  // 不能按"有没有查询条件"静态声明——静态声明会与 items 自相矛盾。
1157
1261
  filter: (() => {
1158
- 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;
1159
1267
  return draftRows > 0
1160
1268
  ? { draftPolicy: "included", draftRows, hint: `本次结果含 ${draftRows} 条 DRAFT 行——total 含草稿,不要当"已提交数据量";判定状态读 data.load 的 sequenceStatus` }
1161
1269
  : { draftPolicy: "unspecified", hint: "本次结果无 DRAFT 行;判定状态以 data.load 的 sequenceStatus 为准" };
@@ -1187,7 +1295,26 @@ export function registerRuntimeDataLane(tools) {
1187
1295
  }
1188
1296
  catch (error) {
1189
1297
  if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode) {
1190
- 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
+ };
1191
1318
  }
1192
1319
  // r30: a MISSING model answers this face with a generic 50000
1193
1320
  // 服务异常 — indistinguishable from a real outage. One field-face
@@ -1266,8 +1393,13 @@ export function registerRuntimeDataLane(tools) {
1266
1393
  else if (totalPages > 0 && requestedPage >= totalPages) {
1267
1394
  pageOutOfRange = true;
1268
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;
1269
1400
  const readback = {
1270
1401
  schemaCode: args.schemaCode,
1402
+ ...application,
1271
1403
  ...(projectionEmpty ? { projectionWarning: { reason: "business-columns-null", hint: "模型可能未配置列表视图:data.list 只投影列表列,业务列会全为 null;核对写入结果请用 data.load(或先建列表视图)" } } : {}),
1272
1404
  ...(args.queryCode !== undefined ? { queryCode: args.queryCode } : {}),
1273
1405
  total,
@@ -1282,7 +1414,6 @@ export function registerRuntimeDataLane(tools) {
1282
1414
  // 统一一个枚举键 `draftPolicy`(included | excluded | unspecified),
1283
1415
  // 不再用两个不同键名表达同一件事(那要调用方写两套分支)。
1284
1416
  ...(() => {
1285
- const draftRows = items.filter((item) => String(item.fields.sequenceStatus ?? "") === "DRAFT").length;
1286
1417
  const policy = draftRows > 0
1287
1418
  ? {
1288
1419
  draftPolicy: "included",
@@ -1456,6 +1587,8 @@ export function registerRuntimeDataLane(tools) {
1456
1587
  ...readbackProjectionArgs,
1457
1588
  },
1458
1589
  execute: async (session, args) => {
1590
+ const applicationScope = await resolveApplicationScope(session);
1591
+ const application = { applicationCode: applicationScope.code, applicationResolution: applicationResolutionOf(applicationScope) };
1459
1592
  // F-A (self-audit): the combo used to be silently ignored (the workflow
1460
1593
  // draft branch returns without honoring submit). Refuse before any wire.
1461
1594
  if (args.submit === true && typeof args.workflowCode === "string" && args.workflowCode.trim())
@@ -1570,8 +1703,8 @@ export function registerRuntimeDataLane(tools) {
1570
1703
  readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: returnedId } })).data;
1571
1704
  sequenceStatus = extractSequenceStatus((readbackData ?? {}));
1572
1705
  return {
1573
- performed: { errcode: performed.errcode, bizObjectId: returnedId, workflowInstanceId, workItemId, workItemSource, sheetCode, resolvedFrom, formType: "save-draft-workflow" },
1574
- 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" },
1575
1708
  };
1576
1709
  }
1577
1710
  let performed;
@@ -1599,9 +1732,12 @@ export function registerRuntimeDataLane(tools) {
1599
1732
  // "rule didn't fire" misdiagnosis.
1600
1733
  const savedRecord = normalizeLoadedRecord((readbackData ?? {}));
1601
1734
  const savedEmptyFields = readbackEmptyFields(formData, savedRecord);
1735
+ const dataWarnings = await runtimeDataWarnings(session, args.schemaCode, sheetCode, formData, 1);
1602
1736
  const plainReadback = {
1737
+ ...application,
1603
1738
  authoritative: projectReadbackRecord(savedRecord, args),
1604
1739
  ...(savedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(savedEmptyFields, "data.save") } : {}),
1740
+ ...(dataWarnings.length > 0 ? { warnings: dataWarnings } : {}),
1605
1741
  draftRuleHint: "草稿态(DRAFT)保存不触发业务规则——DRAFT 行跳过 Create/Update 分发(DomainServiceImpl.java:240-248);要一调完成态本调用加 submit:true;已存草稿直提形状:data.submit {schemaCode, bizObjectId: 本读回的 bizObjectId, formData}(submit 自持久化)",
1606
1742
  };
1607
1743
  // r18 retro (G3): one-call COMPLETED creation — the agent goal is usually
@@ -1650,7 +1786,7 @@ export function registerRuntimeDataLane(tools) {
1650
1786
  };
1651
1787
  }
1652
1788
  return {
1653
- performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom },
1789
+ performed: { errcode: performed.errcode, ...application, bizObjectId, sheetCode, resolvedFrom },
1654
1790
  readback: plainReadback,
1655
1791
  };
1656
1792
  },
@@ -1731,9 +1867,9 @@ export function registerRuntimeDataLane(tools) {
1731
1867
  * gone, not that the model is clean — a submit can leave a separate draft row
1732
1868
  * behind. After a delete, read the model's residue so "cleaned up" is a
1733
1869
  * model-level fact (`total:0`). Best-effort: a query failure never fails the
1734
- * delete, but it IS surfaced as `modelResidueError`.
1870
+ * delete, but it IS surfaced under `preExistingRows.error`.
1735
1871
  */
1736
- async function modelResidue(session, schemaCode, sheetCode) {
1872
+ async function preExistingRows(session, schemaCode, sheetCode) {
1737
1873
  try {
1738
1874
  const result = await wireCall(session, {
1739
1875
  path: ENDPOINTS.runtimeQueryList,
@@ -1741,12 +1877,12 @@ export function registerRuntimeDataLane(tools) {
1741
1877
  body: { schemaCode, queryCode: schemaCode, filters: [], mobile: false, page: 0, size: 1, queryVersion: 1, showTotal: true },
1742
1878
  });
1743
1879
  const page = extractBizObjectPage(result.data);
1744
- return { modelResidue: { total: page.total, clean: page.total === 0, sheetCode } };
1880
+ return { preExistingRows: { total: page.total, clean: page.total === 0, sheetCode, note: "含历史既有数据,非本次创建" } };
1745
1881
  }
1746
1882
  catch (error) {
1747
1883
  if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode)
1748
- return { modelResidue: { total: 0, clean: true, sheetCode } };
1749
- 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) } };
1750
1886
  }
1751
1887
  }
1752
1888
  writeTool({
@@ -1843,14 +1979,15 @@ export function registerRuntimeDataLane(tools) {
1843
1979
  absent: true,
1844
1980
  deleted: false,
1845
1981
  noop: true,
1846
- recoveryHint: "若另一进程刚刚删除,这是正常并发结果(already-absent),不是 id 拿错。否则:真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏。若 modelResidue.total>0,用 items[].id 重删。",
1847
- ...(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)),
1848
1985
  },
1849
1986
  };
1850
1987
  }
1851
1988
  return {
1852
1989
  performed: { errcode: one.errcode, deleted: one.deleted === true, noop: false, sheetCode, resolvedFrom },
1853
- 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)) },
1854
1991
  };
1855
1992
  }
1856
1993
  const deleted = [];
@@ -1858,7 +1995,7 @@ export function registerRuntimeDataLane(tools) {
1858
1995
  deleted.push(await deleteOne(id));
1859
1996
  // `deleted`/`count` ride `readback` — the write envelope surfaces only
1860
1997
  // performed/readback inline (extra top-level keys are artifact-only).
1861
- 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)) } };
1862
1999
  },
1863
2000
  });
1864
2001
  writeTool({
package/dist/mcp-graph.js CHANGED
@@ -58,24 +58,27 @@ const EXPLORE_TOOL = {
58
58
  name: "omc_graph_explore",
59
59
  description: [
60
60
  "一次查全一个目标的引用与影响面(本地索引,毫秒级)。",
61
- "输入 query=字段/模型/服务的 code(或规则检索词);kind 可选 field|model|service,不给则三种都查。",
61
+ "输入 query 可为业务名(实体 name+code 子串)或规则 code+name 词首;也可直接给字段/模型/服务 code。kind 可选 field|model|service,不给则三种都查。",
62
62
  "返回 {index(freshness), references:{kind:[{schemaCode,ruleCode,nodeCode,nodeType,via}]}, impact:{kind:totals}, search:{matches:{model,field,service},rules}}。",
63
+ "references/impact 支持 offset+limit 分页;truncated 时按返回的 nextOffset 继续调用。",
63
64
  "改/删/重命名任何字段、模型、服务之前必查;找\"关于 X 的规则\"拿 code 也走这里。",
64
65
  "索引不含规则条件叶子与流程绑定;freshness=partial 时命中可能不完整(答案里有 gapWarning)。",
65
66
  ].join(" "),
66
67
  inputSchema: {
67
68
  type: "object",
68
69
  properties: {
69
- query: { type: "string", description: "字段/模型/服务的 code,或规则检索词(code+name 词首匹配)" },
70
+ query: { type: "string", description: "业务名(实体 name+code 子串)、字段/模型/服务 code,或规则检索词(规则 code+name 词首匹配)" },
70
71
  kind: { type: "string", enum: [...KINDS], description: "限定一种引用种类;省略则 field/model/service 全查" },
71
72
  env: { type: "string", description: "期望的环境名;与索引所属环境不一致时会拒绝(防跨环境误答)" },
73
+ offset: { type: "integer", minimum: 0, description: "引用/实体结果起始偏移;truncated 时使用 nextOffset" },
74
+ limit: { type: "integer", minimum: 1, maximum: MATCH_CAP, description: "每类结果最多返回多少条(默认 50)" },
72
75
  },
73
76
  required: ["query"],
74
77
  },
75
78
  };
76
79
  const STATUS_TOOL = {
77
80
  name: "omc_graph_status",
78
- description: "索引完整性与新鲜度:environmentName、freshness(complete|partial)、模型/规则计数、pulledAtIso、gaps。回答前先看一眼,索引缺失会给出 pull 指引。",
81
+ description: "索引完整性与新鲜度:environmentName、freshness(complete|partial)、模型/规则计数、pulledAtIso、ageMinutes、staleAfterMinutes、dbPath、coverage.applicationCodes、gaps。超过阈值会给 warning;回答前先看一眼,索引缺失会给出 pull 指引。",
79
82
  inputSchema: { type: "object", properties: {} },
80
83
  };
81
84
  export function graphToolDefinitions() {
@@ -87,8 +90,9 @@ function toolResult(payload, isError = false) {
87
90
  function toolError(message) {
88
91
  return toolResult({ status: "error", message }, true);
89
92
  }
90
- function cap(rows) {
91
- return { rows: rows.slice(0, MATCH_CAP), truncated: rows.length > MATCH_CAP };
93
+ function cap(rows, offset = 0, limit = MATCH_CAP) {
94
+ const page = rows.slice(offset, offset + limit);
95
+ return { rows: page, truncated: offset + page.length < rows.length, offset, limit, ...(offset + page.length < rows.length ? { nextOffset: offset + page.length } : {}), total: rows.length };
92
96
  }
93
97
  /** Shared handler core — exported so tests drive it without a stdio pair. */
94
98
  export async function executeGraphTool(name, rawArgs) {
@@ -111,17 +115,22 @@ export async function executeGraphTool(name, rawArgs) {
111
115
  return toolError(`索引属于环境「${index.environmentName}」;要查「${args.env.trim()}」请先 omc graph pull --env ${args.env.trim()} 重建索引`);
112
116
  }
113
117
  const kinds = kind === undefined ? [...KINDS] : [kind];
118
+ const offset = typeof args.offset === "number" && Number.isSafeInteger(args.offset) && args.offset >= 0 ? args.offset : 0;
119
+ const limit = typeof args.limit === "number" && Number.isSafeInteger(args.limit) && args.limit > 0 && args.limit <= MATCH_CAP ? args.limit : MATCH_CAP;
114
120
  const references = {};
115
121
  const impact = {};
116
122
  for (const one of kinds) {
117
123
  const ref = queryGraphReferences(one, query);
118
- const capped = cap(ref.matches);
124
+ const capped = cap(ref.matches, offset, limit);
119
125
  // r35a-F4c(真机 2026-11-06):**MCP 面是第三个消费者**——CLI 的 references
120
126
  // 与 impact 都带 coverage 了,这里若不显式透传,MCP 用户看到的仍是
121
127
  // 「matches:[] + 一句旧 note」,照样会把「表单没扫」误读成「没人引用」。
122
128
  references[one] = {
123
129
  count: ref.matches.length,
124
- ...capped,
130
+ rows: capped.rows,
131
+ offset: capped.offset,
132
+ limit: capped.limit,
133
+ ...(capped.truncated ? { truncated: true, nextOffset: capped.nextOffset, note: `共 ${capped.total} 条,已给 ${capped.rows.length} 条,offset=${capped.offset} 取下一段(调用时传 offset=${capped.nextOffset}、limit=${capped.limit})` } : {}),
125
134
  ...(ref.coverage ? { coverage: ref.coverage } : {}),
126
135
  ...(ref.gapWarning ? { gapWarning: ref.gapWarning } : {}),
127
136
  };
@@ -129,26 +138,36 @@ export async function executeGraphTool(name, rawArgs) {
129
138
  const imp = impactGraph(one, query);
130
139
  impact[one] = {
131
140
  totals: imp.totals,
132
- rules: cap(imp.rules),
133
- datarules: cap(imp.datarules),
134
- listviews: cap(imp.listviews),
141
+ rules: cap(imp.rules, offset, limit),
142
+ datarules: cap(imp.datarules, offset, limit),
143
+ listviews: cap(imp.listviews, offset, limit),
135
144
  ...(imp.coverage ? { coverage: imp.coverage } : {}),
136
145
  };
137
146
  }
138
147
  }
139
- const search = searchGraphRules(query);
148
+ const search = searchGraphRules(query, limit, offset);
149
+ const resolvedEntity = search.matches.model.items[0] ? { kind: "model", schemaCode: search.matches.model.items[0].schemaCode, code: search.matches.model.items[0].schemaCode } :
150
+ search.matches.field.items[0] ? { kind: "field", schemaCode: search.matches.field.items[0].schemaCode, code: search.matches.field.items[0].code } :
151
+ search.matches.service.items[0] ? { kind: "service", schemaCode: "", code: search.matches.service.items[0].code } :
152
+ search.rules[0] ? { kind: "rule", schemaCode: search.rules[0].schemaCode, code: search.rules[0].ruleCode } : undefined;
153
+ const searchNote = "实体检索:业务名/实体 name+code 使用子串匹配。\n规则检索:规则 code+name 使用词首匹配(中文子串不命中)。";
154
+ const entityTruncated = Object.values(search.matches).some((match) => match.truncated === true);
155
+ const paginationNote = entityTruncated ? `\n结果已截断:共 N 条时请使用对应 match 的 nextOffset,并在下一次调用传 offset=nextOffset、limit=${limit} 取下一段。` : "";
140
156
  return toolResult({
141
157
  status: "ok",
142
158
  query,
159
+ found: resolvedEntity !== undefined,
160
+ ...(resolvedEntity ? { resolved: resolvedEntity } : {}),
143
161
  ...(kind !== undefined ? { kind } : {}),
144
162
  index,
145
163
  references,
146
164
  impact,
147
165
  search: { count: search.rules.length, rules: search.rules, matches: search.matches, coverage: search.coverage },
148
- ...(search.note ? { searchNote: search.note } : {}),
166
+ searchNote: `${searchNote}${paginationNote}`,
167
+ ...(search.note ? { searchNote: `${searchNote}\n${search.note}` } : {}),
149
168
  // 旧句「索引不含规则条件叶子与流程绑定」漏了表单与流程权限(r35a-F4 实证
150
169
  // 探针据此差点误删被表单引用的字段)。改为指向结构化 coverage,不再靠散文。
151
- note: "零命中只代表 references[].coverage.scanned 面没有命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除或无影响",
170
+ note: `零命中只代表 references[].coverage.scanned 面没有命中;本索引覆盖应用:${index.coverage.applicationCodes.join("、") || "(未知)"},不代表其他应用没有结果;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除或无影响`,
152
171
  });
153
172
  }
154
173
  return toolError(`未知工具:${name}`);
@@ -17,6 +17,7 @@ import { wireCall, WireError, wireRows } from "./wire.js";
17
17
  * endpoint — see .scratch/backend-online-coding/recon.md §8).
18
18
  *
19
19
  * Commands:
20
+ * ⚠ test 会 publish 并切编码模式(不可逆)
20
21
  * pull remote → tree + manifest (environment-stamped)
21
22
  * status local edits vs manifest, plus optional remote drift (--deep)
22
23
  * apply changed files → programming.save (→ --publish)
@@ -801,18 +802,30 @@ export async function cmdProgrammingTest(options) {
801
802
  const targetSchema = String(dataSpec.schema ?? schemaCode);
802
803
  const filter = (dataSpec.filter ?? {});
803
804
  const expect = (dataSpec.expect ?? {});
805
+ // data.list is only a locator here. Its projection is intentionally not
806
+ // used as evidence: omitted columns are returned as null and used to turn
807
+ // a correct rule into a false failure (F11).
804
808
  const listed = await callTool("data.list", {
805
809
  environment: session.environmentName,
806
810
  schemaCode: targetSchema,
807
811
  ...filterOf(filter),
808
- displayFields: Object.keys(expect),
812
+ displayFields: ["bizObjectId", "id"],
809
813
  }, context);
810
814
  const rows = (listed.rows ?? listed.items ?? []);
811
- const matched = rows.filter((row) => matchExpectation(row, expect).ok);
815
+ const loadedRows = [];
816
+ for (const row of rows) {
817
+ const id = String(row.bizObjectId ?? row.id ?? "");
818
+ if (!id)
819
+ continue;
820
+ const loaded = await callTool("data.load", { environment: session.environmentName, schemaCode: targetSchema, bizObjectId: id, full: true }, context);
821
+ if (loaded.exists === true && loaded.data && typeof loaded.data === "object")
822
+ loadedRows.push(loaded.data);
823
+ }
824
+ const matched = loadedRows.filter((row) => matchExpectation(row, expect).ok);
812
825
  const ok = matched.length > 0;
813
826
  if (!ok)
814
827
  assertsFailed += 1;
815
- assertReports.push({ schema: targetSchema, filter, expect, rows: rows.length, ok, ...(ok ? {} : { sample: rows.slice(0, 3) }) });
828
+ assertReports.push({ schema: targetSchema, filter, expect, rows: loadedRows.length, ok, ...(ok ? {} : { sample: loadedRows.slice(0, 3) }) });
816
829
  }
817
830
  report.asserts = assertReports;
818
831
  // 5. Cleanup — only rows the fixture names (seed filter), never a blanket key.
@@ -71,7 +71,7 @@ const READ_SHAPES = {
71
71
  keys: ["bizObjectId", "exists", "data:{...}"],
72
72
  },
73
73
  "app.list": {
74
- keys: ["applications:array<{code,name,groupId?}>", "applicationsCount", "omitted"],
74
+ keys: ["applications:array<{code,name,groupId?,modelCount}>", "applicationsCount", "omitted"],
75
75
  notes: ["applicationsCount + omitted 与 discover 同构;omitted:false 表示内联即全集"],
76
76
  },
77
77
  "model.list": {
@@ -85,8 +85,8 @@ const READ_SHAPES = {
85
85
  },
86
86
  "model.inspect": {
87
87
  // Slim/compact face (window diet): field inventory is `fieldCodes`, not fields[].
88
- keys: ["schemaCode", "fieldCodes", "form", "listview", "ruleCodes", "datarules", "workflow", "hint"],
89
- notes: ["slim 面键是 fieldCodes(不是 fields[].code)"],
88
+ keys: ["schemaCode", "applicationCode", "fieldCodes", "form", "listview", "ruleCodes", "datarules", "workflow", "hint"],
89
+ notes: ["slim 面键是 fieldCodes(不是 fields[].code);all:true 时 fields/artifact 行含 options"],
90
90
  },
91
91
  "field.list": {
92
92
  keys: ["fields:array<{code,name,type,...}>", "modelPresent?"],
package/dist/rollback.js CHANGED
@@ -8,6 +8,7 @@ import { readJournal, resolveJournalEntry, laterWritesOnTarget } from "./journal
8
8
  import { canonicalForVerify, deepDiff, pruneNullish } from "./rollback-spec.js";
9
9
  import { sha256 } from "./support.js";
10
10
  import { emitTransportResult } from "./command-transport.js";
11
+ import { acquireWriteLock } from "./write-lock.js";
11
12
  /** Pure runtime assembly: an injected context (tests) or the default one. */
12
13
  function runtime(context) {
13
14
  return context ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
@@ -191,25 +192,53 @@ export async function applyRollback(ref, options, context) {
191
192
  };
192
193
  }
193
194
  const results = [];
194
- for (const [index, call] of plan.inverse.calls.entries()) {
195
- const { exitCode, result } = await runToolCall(call.tool, {
196
- args: { ...call.args, environment: entry.environment, ...(options.confirmProduction ? { confirmProduction: true } : {}) },
197
- requestId: `omc-rollback-${entry.seq}-${index + 1}`,
198
- context: ctx,
199
- print: () => undefined,
200
- });
201
- results.push({
202
- tool: call.tool,
203
- exitCode,
204
- status: result.status,
205
- ...(result.deliveryState ? { deliveryState: result.deliveryState } : {}),
206
- ...(result.summary ? { summary: result.summary } : {}),
207
- // r55: exit-code-only results hid the reason a restore call failed
208
- // (input-invalid showed as a bare "status=error"). Surface the shape.
209
- ...(exitCode === 0 ? {} : { blocker: result.blocker ?? null, message: result.message ?? null, ...(result.issues !== undefined ? { issues: result.issues } : {}), ...(result.diagnostic !== undefined ? { diagnostic: result.diagnostic } : {}) }),
210
- });
211
- if (exitCode !== 0)
212
- return { status: "stopped", rollbackOf: { seq: entry.seq, tool: entry.tool }, step: index + 1, results, message: `恢复调用 ${call.tool} 未成功(status=${result.status});已执行的调用已各自记 journal,可对已完成步骤再 plan` };
195
+ // The inverse plan is intentionally kept as the seam for future inverse
196
+ // extensions (for example onlinejs.compose remove:true): all new restore
197
+ // shapes still become governed calls here, without changing the evidence
198
+ // or lock protocol.
199
+ const waitMs = options.waitMs ?? 0;
200
+ const deadline = Date.now() + waitMs;
201
+ let lock;
202
+ for (;;) {
203
+ lock = await acquireWriteLock(entry.environment);
204
+ if (lock.held)
205
+ break;
206
+ if (Date.now() >= deadline) {
207
+ return {
208
+ status: "refused",
209
+ blocker: "write-lock-held",
210
+ rollbackOf: { seq: entry.seq, tool: entry.tool },
211
+ waitMs,
212
+ lockOwner: lock.owner ?? null,
213
+ message: `回滚写锁被占用;已等待 ${waitMs}ms。可用 --wait <ms> 有界排队后重试`,
214
+ };
215
+ }
216
+ await new Promise((resolve) => setTimeout(resolve, Math.min(200, Math.max(0, deadline - Date.now()))));
217
+ }
218
+ try {
219
+ for (const [index, call] of plan.inverse.calls.entries()) {
220
+ const { exitCode, result } = await runToolCall(call.tool, {
221
+ args: { ...call.args, environment: entry.environment, ...(options.confirmProduction ? { confirmProduction: true } : {}) },
222
+ requestId: `omc-rollback-${entry.seq}-${index + 1}`,
223
+ context: ctx,
224
+ print: () => undefined,
225
+ });
226
+ results.push({
227
+ tool: call.tool,
228
+ exitCode,
229
+ status: result.status,
230
+ ...(result.deliveryState ? { deliveryState: result.deliveryState } : {}),
231
+ ...(result.summary ? { summary: result.summary } : {}),
232
+ // r55: exit-code-only results hid the reason a restore call failed
233
+ // (input-invalid showed as a bare "status=error"). Surface the shape.
234
+ ...(exitCode === 0 ? {} : { blocker: result.blocker ?? null, message: result.message ?? null, ...(result.issues !== undefined ? { issues: result.issues } : {}), ...(result.diagnostic !== undefined ? { diagnostic: result.diagnostic } : {}) }),
235
+ });
236
+ if (exitCode !== 0)
237
+ return { status: "stopped", rollbackOf: { seq: entry.seq, tool: entry.tool }, step: index + 1, results, message: `恢复调用 ${call.tool} 未成功(status=${result.status});已执行的调用已各自记 journal,可对已完成步骤再 plan` };
238
+ }
239
+ }
240
+ finally {
241
+ await lock.release();
213
242
  }
214
243
  // Authoritative verify: re-read the target and compare against the
215
244
  // canonicalized before. "restored" tolerates the write pipeline
@@ -232,6 +261,12 @@ export async function applyRollback(ref, options, context) {
232
261
  rollbackOf: { seq: entry.seq, tool: entry.tool, target: entry.target },
233
262
  calls: results,
234
263
  verify,
264
+ // Stable evidence is present on both the restored and mismatch paths.
265
+ // `recoveredTo` deliberately identifies the original journal target; the
266
+ // replay writes are separately journaled and can be rolled back again.
267
+ restoredSha256: restoredSha,
268
+ matchesBefore: verify === "restored",
269
+ recoveredTo: { seq: entry.seq, ts: entry.ts },
235
270
  ...(verify === "mismatch" ? { beforeSha, restoredSha, message: "恢复调用已完成但读回与 before 存在值冲突(非默认显式化):立即人工核对目标现态(omc rollback plan <seq> 的 diff 可复看差异)" } : {}),
236
271
  hint: "本次恢复的每次调用都走了完整写闸门并已记 journal;恢复本身可再回滚(rollback plan <新 seq>)",
237
272
  };
package/dist/write.js CHANGED
@@ -143,7 +143,7 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
143
143
  const artifactRef = writeArtifact(ctx, `${tool.name}-${randomUUID().slice(0, 8)}.json`, artifact);
144
144
  return { status: executed.status, steps: executed.steps, failedStep: executed.failedStep, ...executed.envelope, ...(args.requestId ? { requestId: args.requestId } : {}), artifactFingerprint: artifactRef.detailSha256 };
145
145
  }
146
- const { performed, readback, steps, halfSuccess, irreversible } = executed;
146
+ const { performed, readback, steps, halfSuccess, irreversible, rollbackReceipt, runtimeVerification } = executed;
147
147
  // Readback gate (live 2026-10-17): a tool that reports `readback.present:false`
148
148
  // has NOT proven its own write — the platform accepted the request but the
149
149
  // object is absent from the authoritative face. "Called without error" must
@@ -204,17 +204,21 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
204
204
  return {
205
205
  status: "ok",
206
206
  deliveryState,
207
+ ...((typeof performed.applicationCode === "string" ? { applicationCode: performed.applicationCode } : typeof readback.applicationCode === "string" ? { applicationCode: readback.applicationCode } : {})),
207
208
  ...(readOnlyEscapeNote !== undefined ? { readOnlyEscape: readOnlyEscapeNote } : {}),
208
209
  performed,
209
210
  readback,
210
211
  ...(readbackWarning ? { readbackWarning } : {}),
211
212
  ...(steps !== undefined ? { steps } : {}),
212
213
  ...(halfSuccess !== undefined ? { halfSuccess } : {}),
214
+ ...(runtimeVerification !== undefined ? { runtimeVerification } : {}),
213
215
  ...(typeof irreversible === "string" ? { irreversible } : {}),
214
216
  // r30a F1: first write → replay:"none"; duplicate only on true replay.
215
217
  ...(args.requestId ? { requestId: args.requestId, idempotency: idempotencyDeclaration(tool.name, "none") } : {}),
216
218
  ...(productionConfirmed ? { productionConfirmed: true } : {}),
217
- ...(rollbackRef ? { rollback: rollbackRef } : {}),
219
+ ...(rollbackRef
220
+ ? { rollback: { ...(rollbackReceipt ?? {}), seq: rollbackRef.seq, ...(rollbackReceipt ? {} : { hint: rollbackRef.hint }) } }
221
+ : {}),
218
222
  summary: writeSummary(tool.name, args, { performed, readback }, "done"),
219
223
  detailFile: artifactRef.detailFile,
220
224
  detailSha256: artifactRef.detailSha256,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.6.17",
3
+ "version": "0.6.18",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "commander": "14.0.3",
58
- "omc-core": "0.6.17",
58
+ "omc-core": "0.6.18",
59
59
  "proper-lockfile": "4.1.2",
60
60
  "write-file-atomic": "6.0.0",
61
61
  "zod": "^3.23.8",
@@ -73,6 +73,6 @@
73
73
  "directory": "packages/omc-cli"
74
74
  },
75
75
  "optionalDependencies": {
76
- "@stalfh233/omc-fake-platform": "0.6.17"
76
+ "@stalfh233/omc-fake-platform": "0.6.18"
77
77
  }
78
78
  }