@stalfh233/omc-cli 0.6.17 → 0.6.19

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.
@@ -7,6 +7,7 @@ import { writeTool } from "../write.js";
7
7
  import { defineTool, ToolRefusalError } from "../tool-types.js";
8
8
  import { wireCall, WireError } from "../wire.js";
9
9
  import { PROGRAMMING_LANE_CONTRACT, PROGRAMMING_WIRE } from "./programming-contract.js";
10
+ import { readModelRecord } from "../model-record.js";
10
11
  const LANE = PROGRAMMING_LANE_CONTRACT.lane;
11
12
  const TOOLS = PROGRAMMING_LANE_CONTRACT.tools;
12
13
  /** metadata.app.CodeType is the NUMERIC index on the wire (8.6.26 source). */
@@ -60,7 +61,8 @@ async function readSource(session, args) {
60
61
  const model = asRecord(response.data);
61
62
  if (model === null)
62
63
  return { present: false, provisioned: false, model: null };
63
- return { present: true, provisioned: typeof model.version === "number", model };
64
+ // get_source can return a generated template without a persisted source row.
65
+ return { present: typeof model.version === "number", provisioned: typeof model.version === "number", model };
64
66
  }
65
67
  catch (error) {
66
68
  if (error instanceof WireError && error.kind === "business" && /PROGRAMMING_SOURCE_NONEXISTENT|源码不存在|source.*nonexistent/i.test(error.message))
@@ -102,6 +104,13 @@ function assertTemplateShape(content, code) {
102
104
  });
103
105
  }
104
106
  }
107
+ function assertProgrammingName(name) {
108
+ if (name === undefined)
109
+ return;
110
+ if (typeof name !== "string" || !/^[A-Za-z][A-Za-z0-9_ -]{0,127}$/.test(name)) {
111
+ throw new ToolRefusalError({ status: "refused", blocker: "programming-name-invalid", message: "businessRuleName 只能以字母开头,并只含字母、数字、下划线、空格或连字符(最多 128 字符)。" });
112
+ }
113
+ }
105
114
  async function listRules(session, schemaCode) {
106
115
  const response = await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode } });
107
116
  return rowsOf(response.data);
@@ -223,6 +232,17 @@ function sourceRollback() {
223
232
  },
224
233
  };
225
234
  }
235
+ function publishRollback() {
236
+ return {
237
+ target: (args) => `programming.publish:${String(args.schemaCode ?? "")}/${String(args.businessRuleCode ?? "")}`,
238
+ // A published version has no authoritative read face and cannot be
239
+ // restored by replaying the draft. Deliberately fail before-capture so the
240
+ // shared journal records rollbackable:false rather than implying a draft
241
+ // restore would undo the runtime version.
242
+ read: async () => { throw new Error("published version has no rollbackable read face"); },
243
+ inverse: () => ({ kind: "irreversible", reason: "无图形化回退端点;删除规则才会连源码一起删" }),
244
+ };
245
+ }
226
246
  export function registerProgrammingLane(tools) {
227
247
  tools.push(defineTool({
228
248
  name: "programming.schemas",
@@ -310,12 +330,14 @@ export function registerProgrammingLane(tools) {
310
330
  },
311
331
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
312
332
  const read = await readSource(session, args);
333
+ const model = await readModelRecord(session, args.schemaCode);
313
334
  const content = typeof read.model?.content === "string" ? read.model.content : null;
314
335
  return {
315
336
  schemaCode: args.schemaCode,
316
337
  businessRuleCode: args.businessRuleCode,
317
338
  code: String(read.model?.code ?? args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode)),
318
339
  present: read.present,
340
+ modelExists: model !== null,
319
341
  provisioned: read.provisioned,
320
342
  className: read.model?.className ?? null,
321
343
  codeType: read.model?.codeType ?? null,
@@ -326,13 +348,15 @@ export function registerProgrammingLane(tools) {
326
348
  contentBytes: content === null ? null : Buffer.byteLength(content, "utf8"),
327
349
  // r28j F7: --search "bizObject" 被写危害文档淹没;API 骨架入口钉在这里。
328
350
  apiHint: 'BizObject 读写骨架:omc help omc-backend-online-coding --section "5. 典型 handler 骨架"',
329
- ...(read.provisioned
330
- ? {
331
- // Draft face always reports published:false after publish too —
332
- // do not let callers treat this boolean as the publish verdict (F11).
333
- publishedNote: "本面 published 来自 version=0 草稿行;发布是否生效请看 programming.publish 的 readback.runtimeEffective / developmentMode+enabled,不要看本字段。",
334
- }
335
- : { hint: "未落库草稿:content 是平台按规则类型生成的模板骨架(version:null)。改完后 programming.save 落库,programming.publish 生效。" }),
351
+ ...(model === null
352
+ ? { hint: `模型 ${args.schemaCode} 不存在;get_source 返回的只是平台模板骨架,不代表源码或模型存在。先用 model.list 核对 schemaCode。` }
353
+ : read.provisioned
354
+ ? {
355
+ // Draft face always reports published:false after publish too —
356
+ // do not let callers treat this boolean as the publish verdict (F11).
357
+ publishedNote: "本面 published 来自 version=0 草稿行;发布是否生效请看 programming.publish 的 readback.runtimeEffective / developmentMode+enabled,不要看本字段。",
358
+ }
359
+ : { hint: "未落库草稿:content 是平台按规则类型生成的模板骨架(version:null)。改完后 programming.save 落库,programming.publish 生效。" }),
336
360
  };
337
361
  }, {
338
362
  target: `programming.get:${args.schemaCode}/${args.businessRuleCode}`,
@@ -442,10 +466,20 @@ export function registerProgrammingLane(tools) {
442
466
  },
443
467
  preflight: (args) => {
444
468
  const code = args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode);
469
+ assertProgrammingName(args.businessRuleName);
445
470
  assertTemplateShape(args.content, code);
446
471
  },
447
- dryRunPayload: async (_session, args) => {
472
+ dryRunPayload: async (session, args) => {
448
473
  const code = args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode);
474
+ const rule = await ruleOf(session, args.schemaCode, args.businessRuleCode);
475
+ if (!rule) {
476
+ throw new ToolRefusalError({
477
+ status: "refused",
478
+ blocker: "programming-rule-absent",
479
+ message: `业务规则 ${args.schemaCode}/${args.businessRuleCode} 不存在;save 不会凭空创建规则行。先用 rule.save(或 rule.list)确认/创建规则,再保存源码。`,
480
+ nextCommand: `omc call rule.save --env local --args '{"schemaCode":"${args.schemaCode}","ruleCode":"${args.businessRuleCode}"}'`,
481
+ });
482
+ }
449
483
  return {
450
484
  code,
451
485
  contentSha256: sha256(args.content),
@@ -538,6 +572,8 @@ export function registerProgrammingLane(tools) {
538
572
  const publishStatusRaw = readbackRule?.publishStatus;
539
573
  return {
540
574
  performed: { errcode: 0, code, contentSha256: sha256(content), contentBytes: Buffer.byteLength(content, "utf8"), compiled: true },
575
+ rollbackReceipt: { supported: false, reason: "无图形化回退端点;rollback plan 亦标 irreversible" },
576
+ runtimeVerification: { note: "自定义规则无调用响应体读面;验证须写副作用后用 data.load 读取" },
541
577
  readback: {
542
578
  present: readbackSource.present,
543
579
  contentMatches: readbackContent === content,
@@ -554,19 +590,36 @@ export function registerProgrammingLane(tools) {
554
590
  // so it cannot contradict runtimeEffective:"published".
555
591
  ...(readbackSource.model?.published === true ? { publishedFlag: true } : { publishedFlagNote: "get_source.published 读的是 version=0 草稿行,发布后仍常为 false;不是发布成败判据(published 内容无 wire 读端点)" }),
556
592
  note: "运行时执行的是 published 最新版;published 内容无 wire 读取端点,回滚靠本地 git + 重新 publish。判发布是否生效:authoritative=developmentMode+enabled(1/CODING + true)。",
593
+ rollback: { supported: false, reason: "无图形化回退端点;删除规则才会连源码一起删" },
594
+ runtimeVerification: { note: "自定义规则无调用响应体读面;须写副作用(如字段赋值)后 data.load 验证" },
557
595
  },
558
596
  };
559
597
  },
560
598
  preflight: (args) => {
561
599
  const code = args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode);
600
+ assertProgrammingName(args.businessRuleName);
562
601
  if (args.content !== undefined)
563
602
  assertTemplateShape(args.content, code);
564
603
  },
565
- dryRunPayload: async (_session, args) => ({
566
- code: args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode),
567
- contentSha256: args.content === undefined ? null : sha256(args.content),
568
- note: "dry-run 已执行模板校验;真写会 compile→publish(插新 published 版本行并把规则切 CODING+enable)。",
569
- }),
604
+ dryRunPayload: async (session, args) => {
605
+ const code = args.code ?? businessRuleSourceCode(args.schemaCode, args.businessRuleCode);
606
+ const rule = await ruleOf(session, args.schemaCode, args.businessRuleCode);
607
+ if (!rule)
608
+ throw new ToolRefusalError({ status: "refused", blocker: "programming-rule-absent", message: `业务规则 ${args.schemaCode}/${args.businessRuleCode} 不存在:publish 必须携带 businessRule 整模型。` });
609
+ let content = args.content;
610
+ if (content === undefined) {
611
+ const read = await readSource(session, { schemaCode: args.schemaCode, businessRuleCode: args.businessRuleCode });
612
+ content = typeof read.model?.content === "string" ? read.model.content : undefined;
613
+ }
614
+ if (content === undefined || content.trim() === "")
615
+ throw new ToolRefusalError({ status: "refused", blocker: "programming-content-required", message: "没有可发布的源码:传 content,或先 programming.save 落库草稿。" });
616
+ assertTemplateShape(content, code);
617
+ const compiled = await compileSource(session, code, content);
618
+ if (!compiled.success)
619
+ throw new ToolRefusalError({ status: "refused", blocker: "programming-compile-failed", message: "编译失败,拒绝 dry-run(真写也会拒绝发布)。", errors: compiled.presented.errors ?? compiled.errors, userErrors: compiled.presented.userErrors, platformErrors: compiled.presented.platformErrors });
620
+ return { code, contentSha256: sha256(content), contentBytes: Buffer.byteLength(content, "utf8"), note: "dry-run 与真写执行相同的规则存在、模板形状、编译 preflight;不会写入或切 CODING。" };
621
+ },
622
+ rollback: publishRollback(),
570
623
  });
571
624
  }
572
625
  export { PROGRAMMING_WIRE };
@@ -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?"],