@stalfh233/omc-cli 0.6.16 → 0.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/graph.js CHANGED
@@ -46,10 +46,25 @@ function nodeSqlite() {
46
46
  * staleness is handled by reporting pulledAt + identity, refreshed by an
47
47
  * explicit re-pull (no incremental watermarks, no write-back hooks).
48
48
  */
49
- const GRAPH_SCHEMA_VERSION = "4";
49
+ const GRAPH_SCHEMA_VERSION = "5";
50
50
  const STALE_LOCK_MS = 15 * 60 * 1000;
51
51
  export const DEFAULT_PULL_CONCURRENCY = 16;
52
52
  export const MAX_PULL_CONCURRENCY = 32;
53
+ /** 索引超过这个天数就提示刷新(配置类查询,1 天足够新)。 */
54
+ export const STALE_INDEX_DAYS = 1;
55
+ export const STALE_AFTER_MINUTES = STALE_INDEX_DAYS * 24 * 60;
56
+ export function indexAgeDays(pulledAtIso, now = Date.now()) {
57
+ const pulled = Date.parse(pulledAtIso);
58
+ if (Number.isNaN(pulled))
59
+ return 0;
60
+ return Math.max(0, Math.floor((now - pulled) / 86_400_000));
61
+ }
62
+ export function indexAgeMinutes(pulledAtIso, now = Date.now()) {
63
+ const pulled = Date.parse(pulledAtIso);
64
+ if (Number.isNaN(pulled))
65
+ return 0;
66
+ return Math.max(0, Math.floor((now - pulled) / 60_000));
67
+ }
53
68
  export class GraphIndexAbsentError extends Error {
54
69
  constructor(message) {
55
70
  super(message);
@@ -179,13 +194,39 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
179
194
  // zero-guard) wedge the scheduler.
180
195
  const scope = await resolveApplicationScope(session, undefined).catch(() => undefined);
181
196
  const applicationCode = scope?.code ?? "";
182
- const schemaCodes = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data))
183
- .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "")
184
- .map((row) => String(row.code))
185
- .sort();
197
+ const gaps = [];
198
+ const treeRows = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
199
+ const modelEntities = treeRows.filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "").map((row) => ({ schemaCode: String(row.code), name: String(row.name ?? row.code) })).sort((a, b) => a.schemaCode.localeCompare(b.schemaCode));
200
+ const schemaCodes = modelEntities.map((row) => row.schemaCode);
201
+ const fields = [];
202
+ const services = [];
203
+ await Promise.all(schemaCodes.map(async (schemaCode) => {
204
+ try {
205
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
206
+ for (const row of rows) {
207
+ const code = String(row.code ?? row.propertyCode ?? "");
208
+ if (code)
209
+ fields.push({ schemaCode, code, name: String(row.name ?? row.propertyName ?? code) });
210
+ }
211
+ }
212
+ catch (error) {
213
+ gaps.push({ kind: "field-list", ref: schemaCode, note: error instanceof Error ? error.message : String(error) });
214
+ }
215
+ }));
216
+ try {
217
+ await wireCall(session, { path: ENDPOINTS.bizServiceCategoryList });
218
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.bizServiceListByCategoryId, query: { categoryId: "" } })).data);
219
+ for (const row of rows) {
220
+ const code = String(row.code ?? "");
221
+ if (code)
222
+ services.push({ code, name: String(row.name ?? code) });
223
+ }
224
+ }
225
+ catch (error) {
226
+ gaps.push({ kind: "service-list", ref: "all", note: error instanceof Error ? error.message : String(error) });
227
+ }
186
228
  const rules = [];
187
229
  const edges = [];
188
- const gaps = [];
189
230
  let parseFailures = 0;
190
231
  let dataruleCount = 0;
191
232
  let viewCount = 0;
@@ -370,11 +411,15 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
370
411
  identityFingerprint: identity,
371
412
  environmentName: session.environmentName,
372
413
  applicationCode,
414
+ coverage: { applicationCodes: [applicationCode] },
373
415
  ...(scope !== undefined ? { applicationResolution: applicationResolutionOf(scope) } : {}),
374
416
  pulledAtIso,
375
417
  },
376
418
  rules,
377
419
  edges,
420
+ models: modelEntities,
421
+ fields: fields.sort((a, b) => `${a.schemaCode}/${a.code}`.localeCompare(`${b.schemaCode}/${b.code}`)),
422
+ services: services.sort((a, b) => a.code.localeCompare(b.code)),
378
423
  gaps,
379
424
  scanned: { models: schemaCodes.length, rules: rules.length, datarules: dataruleCount, views: viewCount, parseFailures },
380
425
  timings: { listMs, readMs, totalMs: Date.now() - totalStart },
@@ -489,7 +534,10 @@ export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCU
489
534
  via TEXT NOT NULL
490
535
  );
491
536
  CREATE INDEX edges_lookup ON edges(kind, code);
492
- CREATE TABLE gaps (kind TEXT NOT NULL, ref TEXT NOT NULL, note TEXT);
537
+ CREATE TABLE gaps (kind TEXT NOT NULL, ref TEXT NOT NULL, note TEXT);
538
+ CREATE TABLE models (schema_code TEXT PRIMARY KEY, name TEXT NOT NULL);
539
+ CREATE TABLE fields (schema_code TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL);
540
+ CREATE TABLE services (code TEXT PRIMARY KEY, name TEXT NOT NULL);
493
541
  `);
494
542
  const insertMeta = db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)");
495
543
  // `applicationResolution` is an object; String(value) would persist
@@ -506,12 +554,21 @@ export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCU
506
554
  const insertGap = db.prepare("INSERT INTO gaps (kind, ref, note) VALUES (?, ?, ?)");
507
555
  for (const gap of data.gaps)
508
556
  insertGap.run(gap.kind, gap.ref, gap.note ?? null);
557
+ const insertModel = db.prepare("INSERT INTO models (schema_code, name) VALUES (?, ?)");
558
+ for (const row of data.models)
559
+ insertModel.run(row.schemaCode, row.name);
560
+ const insertField = db.prepare("INSERT INTO fields (schema_code, code, name) VALUES (?, ?, ?)");
561
+ for (const row of data.fields)
562
+ insertField.run(row.schemaCode, row.code, row.name);
563
+ const insertService = db.prepare("INSERT INTO services (code, name) VALUES (?, ?)");
564
+ for (const row of data.services)
565
+ insertService.run(row.code, row.name);
509
566
  // FTS5 mirrors rules (code+name) for `graph search`; one rebuild after
510
567
  // the bulk insert is cheaper than per-row triggers on a wholesale rebuild.
511
568
  db.exec(`
512
- CREATE VIRTUAL TABLE rules_fts USING fts5(rule_code, name, content='rules', content_rowid='rowid');
513
- INSERT INTO rules_fts(rules_fts) VALUES('rebuild');
514
- `);
569
+ CREATE VIRTUAL TABLE rules_fts USING fts5(rule_code, name, content='rules', content_rowid='rowid');
570
+ INSERT INTO rules_fts(rules_fts) VALUES('rebuild');
571
+ `);
515
572
  db.exec("COMMIT;");
516
573
  }
517
574
  finally {
@@ -544,6 +601,11 @@ function openGraphIndex() {
544
601
  throw new GraphIndexAbsentError(`索引 schema 版本不匹配(${meta.schemaVersion} ≠ ${GRAPH_SCHEMA_VERSION});重跑 omc graph pull`);
545
602
  const ruleCount = db.prepare("SELECT COUNT(*) AS n FROM rules").get().n;
546
603
  const modelCount = db.prepare("SELECT COUNT(DISTINCT schema_code) AS n FROM rules").get().n;
604
+ const entityCounts = {
605
+ models: db.prepare("SELECT COUNT(*) AS n FROM models").get().n,
606
+ fields: db.prepare("SELECT COUNT(*) AS n FROM fields").get().n,
607
+ services: db.prepare("SELECT COUNT(*) AS n FROM services").get().n,
608
+ };
547
609
  const edgeRows = db.prepare("SELECT kind, COUNT(*) AS n FROM edges GROUP BY kind").all();
548
610
  const edges = {};
549
611
  for (const row of edgeRows)
@@ -555,19 +617,33 @@ function openGraphIndex() {
555
617
  byKind[row.kind] = row.n;
556
618
  gapCount += row.n;
557
619
  }
620
+ const pulledAtIso = meta.pulledAtIso ?? "";
621
+ const ageDays = indexAgeDays(pulledAtIso);
622
+ const ageMinutes = indexAgeMinutes(pulledAtIso);
623
+ const coverage = { applicationCodes: meta.applicationCode ? [meta.applicationCode] : [] };
558
624
  return {
559
625
  db,
560
626
  summary: {
561
627
  environmentName: meta.environmentName ?? "",
562
628
  applicationCode: meta.applicationCode ?? "",
629
+ coverage,
563
630
  // Persisted as JSON by the writer; a legacy index (pre-0.5.4) has no
564
631
  // such key, so this stays optional rather than failing the read.
565
632
  ...(meta.applicationResolution ? { applicationResolution: parseApplicationResolution(meta.applicationResolution) } : {}),
566
633
  identityFingerprint: meta.identityFingerprint ?? "",
567
- pulledAtIso: meta.pulledAtIso ?? "",
634
+ pulledAtIso,
568
635
  freshness: gapCount === 0 ? "complete" : "partial",
636
+ ageDays,
637
+ ageMinutes,
638
+ staleAfterMinutes: STALE_AFTER_MINUTES,
639
+ // r31a-F16:旧索引 + complete 标签 = 静默错答。给可直接照抄的刷新命令,
640
+ // 并明确「complete 只指扫描无缺口」。
641
+ ...(ageDays >= STALE_INDEX_DAYS
642
+ ? { staleWarning: `索引是 ${ageDays} 天前的快照(pulledAt ${pulledAtIso});freshness:"complete" 只表示扫描无缺口、不表示最新。先 \`omc graph pull --env ${meta.environmentName ?? "<env>"}\` 再查,或给结论加时效限定` }
643
+ : {}),
569
644
  models: modelCount,
570
645
  rules: ruleCount,
646
+ entities: entityCounts,
571
647
  edges,
572
648
  gaps: { count: gapCount, byKind },
573
649
  },
@@ -578,6 +654,17 @@ function openGraphIndex() {
578
654
  throw error instanceof GraphIndexAbsentError ? error : new GraphIndexAbsentError(`索引不可读(schema 不匹配或文件损坏):${error instanceof Error ? error.message : String(error)};重跑 omc graph pull`);
579
655
  }
580
656
  }
657
+ /**
658
+ * r35a-F4 / r35a-F4b(真机 2026-11-06):探针拿 `matches:[]` 差点把一个**被表单引用**
659
+ * 的字段判成「可安全删除」,紧接着发现 `graph impact` 的 `totals` 全 0 也有同样误读风险。
660
+ * 三个面(CLI references / CLI impact / MCP explore)必须共用同一份声明——
661
+ * 分散定义就会被漏掉一处(MCP 面第一版就漏了)。
662
+ */
663
+ export const GRAPH_COVERAGE = Object.freeze({
664
+ scanned: Object.freeze(["businessrule", "datarule", "listview"]),
665
+ notScanned: Object.freeze(["form", "workflow.permissions", "workflow.bindings", "onlinejs", "rule.conditions"]),
666
+ note: "零命中/全零 totals 只代表 scanned 面没有命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除或无影响",
667
+ });
581
668
  /**
582
669
  * Zero-hit disambiguation (2026-10-31 capability-test friction, hit twice in
583
670
  * one session): querying `impact field mcc_supplier` returns an empty result
@@ -611,11 +698,7 @@ export function queryGraphReferences(kind, code) {
611
698
  // 字段判成「可安全删除」——note 只说了「不含规则条件叶子/流程绑定」,从没
612
699
  // 明说表单与流程权限表不在扫描范围。散文字段不承担安全契约:这里给结构化
613
700
  // coverage,让「没人引用」与「这一面没扫」在机器可读层面可分。
614
- const coverage = {
615
- scanned: ["businessrule", "datarule", "listview"],
616
- notScanned: ["form", "workflow.permissions", "workflow.bindings", "onlinejs", "rule.conditions"],
617
- note: "matches:[] 只代表 scanned 面零命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除",
618
- };
701
+ const coverage = GRAPH_COVERAGE;
619
702
  return {
620
703
  kind,
621
704
  code,
@@ -626,7 +709,7 @@ export function queryGraphReferences(kind, code) {
626
709
  ? {
627
710
  note: (gapped
628
711
  ? "零命中,但索引存在扫描缺口——不可作为「无引用」证明;先 omc graph pull 重建再查"
629
- : "零命中=仅 scanned 面(规则/数据规则/视图)没有引用它;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定可安全删除") +
712
+ : `零命中=仅 scanned 面(规则/数据规则/视图)没有引用它;本索引覆盖应用:${summary.coverage.applicationCodes.join("、") || "(未知)"},不代表其他应用没有引用;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定可安全删除`) +
630
713
  (zeroHitKindHint(db, kind, code, "references") ?? ""),
631
714
  }
632
715
  : {}),
@@ -643,7 +726,18 @@ export function readGraphStatus() {
643
726
  const { db, summary } = openGraphIndex();
644
727
  try {
645
728
  const gaps = db.prepare("SELECT kind, ref, note FROM gaps ORDER BY kind, ref LIMIT 50").all();
646
- return { index: summary, dbBytes: statSync(graphDbPath()).size, gaps };
729
+ const dbPath = graphDbPath();
730
+ return {
731
+ index: summary,
732
+ dbBytes: statSync(dbPath).size,
733
+ dbPath,
734
+ dbId: sha256(dbPath).slice(0, 12),
735
+ ageMinutes: summary.ageMinutes,
736
+ staleAfterMinutes: summary.staleAfterMinutes,
737
+ ...(summary.staleWarning ? { warning: summary.staleWarning } : {}),
738
+ coverage: summary.coverage,
739
+ gaps,
740
+ };
647
741
  }
648
742
  finally {
649
743
  db.close();
@@ -656,18 +750,23 @@ export function readGraphStatus() {
656
750
  * a prefix match (`引用*`), which is also what makes CJK usable: unicode61
657
751
  * stores a CJK run as one token, so a full-run query hits only by prefix.
658
752
  */
659
- export function searchGraphRules(term, limit = 20) {
753
+ export function searchGraphRules(term, limit = 20, offset = 0) {
660
754
  const { db, summary } = openGraphIndex();
661
755
  try {
662
756
  const tokens = term.trim().split(/\s+/).filter((token) => token !== "");
663
757
  let matches = [];
758
+ const entityMatches = {
759
+ model: { count: 0, total: 0, items: [] },
760
+ field: { count: 0, total: 0, items: [] },
761
+ service: { count: 0, total: 0, items: [] },
762
+ };
664
763
  if (tokens.length > 0) {
665
764
  const quoted = tokens.map((token, index) => `"${token.replaceAll('"', '""')}"${index === tokens.length - 1 ? "*" : ""}`).join(" ");
666
765
  const rows = db
667
766
  .prepare(`SELECT r.schema_code, r.rule_code, r.name, r.enabled, r.status
668
767
  FROM rules_fts JOIN rules r ON r.rowid = rules_fts.rowid
669
- WHERE rules_fts MATCH ? ORDER BY bm25(rules_fts), r.schema_code, r.rule_code LIMIT ?`)
670
- .all(quoted, limit);
768
+ WHERE rules_fts MATCH ? ORDER BY bm25(rules_fts), r.schema_code, r.rule_code LIMIT ? OFFSET ?`)
769
+ .all(quoted, limit, offset);
671
770
  const refsFor = db.prepare("SELECT kind, code FROM edges WHERE schema_code = ? AND rule_code = ? ORDER BY kind, code");
672
771
  matches = rows.map((row) => {
673
772
  const refs = { service: [], model: [], field: [] };
@@ -683,20 +782,38 @@ export function searchGraphRules(term, limit = 20) {
683
782
  refs,
684
783
  };
685
784
  });
785
+ const searchEntity = (countSql, rowsSql, params, map) => {
786
+ const total = Number(db.prepare(countSql).get(...params).n);
787
+ const rows = db.prepare(rowsSql.replace("LIMIT ?", "LIMIT ? OFFSET ?")).all(...params, limit, offset);
788
+ return {
789
+ count: rows.length,
790
+ total,
791
+ items: rows.map(map),
792
+ offset,
793
+ limit,
794
+ ...(offset + rows.length < total ? { truncated: true, nextOffset: offset + rows.length } : {}),
795
+ };
796
+ };
797
+ entityMatches.model = searchEntity("SELECT COUNT(*) AS n FROM models WHERE instr(name, ?) > 0 OR instr(schema_code, ?) > 0", "SELECT schema_code, name FROM models WHERE instr(name, ?) > 0 OR instr(schema_code, ?) > 0 ORDER BY schema_code LIMIT ?", [term.trim(), term.trim()], (row) => ({ schemaCode: row.schema_code, name: row.name }));
798
+ entityMatches.field = searchEntity("SELECT COUNT(*) AS n FROM fields WHERE instr(name, ?) > 0 OR instr(code, ?) > 0", "SELECT schema_code, code, name FROM fields WHERE instr(name, ?) > 0 OR instr(code, ?) > 0 ORDER BY schema_code, code LIMIT ?", [term.trim(), term.trim()], (row) => ({ schemaCode: row.schema_code, code: row.code, name: row.name }));
799
+ entityMatches.service = searchEntity("SELECT COUNT(*) AS n FROM services WHERE instr(name, ?) > 0 OR instr(code, ?) > 0", "SELECT code, name FROM services WHERE instr(name, ?) > 0 OR instr(code, ?) > 0 ORDER BY code LIMIT ?", [term.trim(), term.trim()], (row) => ({ code: row.code, name: row.name }));
686
800
  }
687
801
  const gapped = summary.gaps.count > 0;
688
802
  return {
689
803
  term,
690
804
  rules: matches,
805
+ matches: entityMatches,
691
806
  index: summary,
692
- ...(matches.length === 0
807
+ coverage: { scanned: ["rule.code", "rule.name", "model.code", "model.name", "field.code", "field.name", "service.code", "service.name"], notScanned: ["rule.body", "form", "workflow.permissions"], note: "检索覆盖规则与实体的 code/name;规则体、表单与流程权限未扫描" },
808
+ ...(term.trim() !== "" && !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(term.trim()) ? { note: "疑似业务词;本工具同时检索实体 name/code 与规则 code/name,引用明细请使用匹配到的 code 再查 references/impact" } : {}),
809
+ ...(matches.length === 0 && entityMatches.model.total === 0 && entityMatches.field.total === 0 && entityMatches.service.total === 0
693
810
  ? {
694
811
  note: tokens.length === 0
695
812
  ? "检索词为空"
696
813
  // 实测摩擦(2026-10-30,zlt测试):中文子串 0 命中后没有下一步提示,
697
814
  // 调用方在「换个词再试」里打转。把两个出口都写进 note:CJK 词首
698
815
  // 通配 + 找引用走 references/impact。
699
- : "没有命中规则。索引只覆盖规则的 code+name(不含规则体);且只匹配**词首**——FTS5 把「增加预算」当一个词,「预算」匹配不到它。请给全词(增加预算)、词首(增加 或 增加*),或英文 code 前缀。找\"谁引用了 X\"请走 references/impact <kind> <code>。",
816
+ : `实体检索:没有命中模型/字段/服务的 name 或 code;本索引覆盖应用:${summary.coverage.applicationCodes.join("、") || "(未知)"},不代表其他应用没有该实体。\n规则检索:没有命中规则 code/name;规则搜索只匹配词首(例如「增加预算」是一个词,「预算」匹配不到),请给全词、词首或英文 code 前缀。找"谁引用了 X"请走 references/impact <kind> <code>。`,
700
817
  }
701
818
  : {}),
702
819
  ...(gapped ? { gapWarning: `索引有 ${summary.gaps.count} 个扫描缺口;命中列表可能不完整` } : {}),
@@ -767,11 +884,12 @@ export function impactGraph(kind, code) {
767
884
  listviews: listViewHits.slice(0, 20).map((hit) => ({ schemaCode: hit.schema_code, viewCode: hit.view_code ?? "", via: hit.via })),
768
885
  totals: { rules: hop1.length, datarules: dataruleHits.length, listviews: listViewHits.length, touchedServices: services.size, touchedModels: models.size, touchedFields: fields.size },
769
886
  index: summary,
887
+ coverage: GRAPH_COVERAGE,
770
888
  ...(hop1.length === 0 && dataruleHits.length === 0 && listViewHits.length === 0
771
889
  ? {
772
890
  note: (gapped
773
891
  ? "零命中,但索引存在扫描缺口——不可作为「无影响」证明;先 omc graph pull 重建再查"
774
- : "零命中=索引覆盖范围内没有引用它(规则面+数据规则面+视图列/排序/条件面;流程绑定暂不在索引内)") +
892
+ : "零命中=仅 scanned 面(规则/数据规则/视图)没有影响;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定「删除无影响」") +
775
893
  (zeroHitKindHint(db, kind, code, "impact") ?? ""),
776
894
  }
777
895
  : {}),
package/dist/lanes/app.js CHANGED
@@ -5,10 +5,22 @@ import { governedRead } from "../read.js";
5
5
  import { writeTool } from "../write.js";
6
6
  import { defineTool, ToolRefusalError } from "../tool-types.js";
7
7
  import { wireCall, wireRows, WireError } from "../wire.js";
8
+ import { resolveApplicationScope } from "../support.js";
8
9
  import { APP_LANE_CONTRACT } from "./app-contract.js";
9
10
  import { readAppTree } from "./folder.js";
10
11
  const APP_LANE = APP_LANE_CONTRACT.lane;
11
12
  const APP_TOOLS = APP_LANE_CONTRACT.tools;
13
+ export async function resolveValidatedApplicationScope(session, requested) {
14
+ const scope = await resolveApplicationScope(session, requested);
15
+ const availableApps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data)
16
+ .map((row) => String(row.appCode ?? row.code ?? "")).filter((code) => code !== "");
17
+ if (availableApps.length > 0 && !availableApps.includes(scope.code)) {
18
+ throw new ToolRefusalError({ status: "refused", blocker: "app-not-found", applicationCode: scope.code,
19
+ applicationResolution: { status: "unresolved", source: scope.source, requested: scope.requested ?? scope.code, available: availableApps.slice(0, 40) },
20
+ message: `应用「${scope.code}」不在租户 app.list 里(不是「该应用没有模型」)。可用:${availableApps.slice(0, 12).join("、")}${availableApps.length > 12 ? " 等" : ""};先 app.list 取正确 applicationCode。` });
21
+ }
22
+ return scope;
23
+ }
12
24
  function appRollbackSpec(toolName) {
13
25
  return {
14
26
  target: (args) => `app:${String(args.code ?? "")}`,
@@ -48,10 +60,11 @@ export function registerAppLane(tools) {
48
60
  description: APP_TOOLS["app.list"].description,
49
61
  inputSchema: { environment: environmentArg, capability: capabilityArg },
50
62
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
51
- const applications = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).map((row) => ({
52
- code: String(row.appCode ?? row.code),
53
- name: row.appName ?? row.name,
54
- groupId: row.groupId,
63
+ const applications = await Promise.all(wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).map(async (row) => {
64
+ const code = String(row.appCode ?? row.code);
65
+ const tree = await readAppTree(session, code);
66
+ const modelCount = tree.reduce((count, node) => count + (node.type === "BizModel" ? 1 : 0), 0);
67
+ return { code, name: row.appName ?? row.name, groupId: row.groupId, modelCount };
55
68
  }));
56
69
  // r29f F2:与 discover_environment 同构——一眼判断「内联是否全集」。
57
70
  // 应用通常个位数,默认内联 + omitted:false;大租户由 governedRead 外置时
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
3
  import { fieldChangeWireBody, fieldUpdateWireBody, prepareFieldChange, prepareFieldUpdate } from "../field-change.js";
4
- import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, } from "../field-families.js";
4
+ import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, assertFieldFamilyAllowed, assertSelectionChoicesAllowed, assertRelationTargetExists, relationPublishFailureMessage, } from "../field-families.js";
5
5
  import { capabilityArg, environmentArg } from "../args.js";
6
6
  import { governedRead } from "../read.js";
7
7
  import { writeTool } from "../write.js";
@@ -12,10 +12,29 @@ import { defineTool, ToolRefusalError } from "../tool-types.js";
12
12
  import { wireCall, wireRows, WireError } from "../wire.js";
13
13
  import { executeFormDraftWrite, executeFormPublishWrite, readFormOrNull } from "../form-support.js";
14
14
  import { FIELD_LANE_CONTRACT } from "./field-contract.js";
15
- import { mapBounded } from "../support.js";
15
+ import { flattenTreeRows, mapBounded, resolveApplicationScope } from "../support.js";
16
16
  import { extractBizObjectPage } from "./runtime-data.js";
17
17
  const FIELD_LANE = FIELD_LANE_CONTRACT.lane;
18
18
  const FIELD_TOOLS = FIELD_LANE_CONTRACT.tools;
19
+ /** Read actual model schema codes; bare modelList may expose internal node ids. */
20
+ export async function modelSchemaCodes(session) {
21
+ try {
22
+ const scope = await resolveApplicationScope(session);
23
+ const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: scope.code } })).data));
24
+ const codes = tree
25
+ .filter((row) => row.type === "BizModel")
26
+ .map((row) => String(row.code ?? row.schemaCode ?? row.bizSchemaCode ?? ""))
27
+ .filter((code) => code.length > 0);
28
+ if (codes.length > 0)
29
+ return codes;
30
+ }
31
+ catch {
32
+ // Fall through to the compatibility endpoint below.
33
+ }
34
+ return wireRows((await wireCall(session, { path: ENDPOINTS.modelList, query: {} })).data)
35
+ .map((row) => String(row.schemaCode ?? row.bizSchemaCode ?? row.code ?? ""))
36
+ .filter((code) => code.length > 0);
37
+ }
19
38
  async function readFieldRollback(session, args) {
20
39
  for (const published of [false, true]) {
21
40
  const row = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: String(args.schemaCode), isPublish: String(published) } })).data).find((entry) => String(entry.code ?? entry.propertyCode) === String(args.code));
@@ -377,6 +396,8 @@ export function registerFieldLane(tools) {
377
396
  // wire body (src/field-change.ts), so configure_model and intent share it.
378
397
  assertNoSelfSchemaAggregation(args, published);
379
398
  await assertNoChildSchemaAggregation(session, args.schemaCode, args.propertyType, args.options ?? {});
399
+ const models = await modelSchemaCodes(session);
400
+ assertRelationTargetExists({ ...args }, models);
380
401
  // 目标版本决定 optionsSet 形状:8.2 以下要补 optionStructure/treeSelect
381
402
  // (cpdocs issue #8:缺键让设计器选项弹窗硬崩)。
382
403
  const targetVersion = session.detectedVersion();
@@ -434,6 +455,7 @@ export function registerFieldLane(tools) {
434
455
  readback: {
435
456
  face: "draft",
436
457
  draftFieldPresent: rows.some((row) => (row.code ?? row.propertyCode) === args.code),
458
+ ...(prepared.normalized ? { normalized: prepared.normalized } : {}),
437
459
  ...(textLike
438
460
  ? {
439
461
  sideEffectsNote: "平台可能为文本/下拉字段自动生成一条 datarule type1 checkType=4(maxLength≈200);本调用未显式创建。清理/对账请用 datarule.list 核对,不要把自动行当成重复写入。",
@@ -442,6 +464,10 @@ export function registerFieldLane(tools) {
442
464
  },
443
465
  };
444
466
  },
467
+ preflight: (args) => {
468
+ assertSelectionChoicesAllowed(args);
469
+ assertFieldFamilyAllowed(args);
470
+ },
445
471
  rollback: fieldRollbackSpec("field.create"),
446
472
  });
447
473
  writeTool({
@@ -633,6 +659,15 @@ async function loadFieldRowForUpdate(session, schemaCode, code) {
633
659
  const record = rows.find((row) => (row.code ?? row.propertyCode) === code);
634
660
  if (!record)
635
661
  throw new WireError("business", `field ${code} not found on ${schemaCode}`, 301001);
662
+ // Some platform list faces are diet projections and omit the display name;
663
+ // update readback must still preserve the existing name/name_i18n.
664
+ try {
665
+ const detail = (await wireCall(session, { path: ENDPOINTS.fieldGet, query: { schemaCode, propertyCode: code } })).data;
666
+ return { ...detail, ...record, name: record.name ?? detail.name, name_i18n: record.name_i18n ?? detail.name_i18n };
667
+ }
668
+ catch {
669
+ // The detail face is optional; retain the list row.
670
+ }
636
671
  return record;
637
672
  }
638
673
  async function countModelDataRows(session, schemaCode) {
@@ -1027,6 +1062,58 @@ async function runFieldUpdate(session, args) {
1027
1062
  ...(args.propertyEmpty !== undefined ? { propertyEmpty: args.propertyEmpty } : {}),
1028
1063
  };
1029
1064
  const prepared = prepareFieldUpdate(update);
1065
+ const currentOptions = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
1066
+ const requestedChanges = [];
1067
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
1068
+ if (args.name !== undefined && !same(record.name ?? null, prepared.name ?? args.name))
1069
+ requestedChanges.push("name");
1070
+ if (args.name_i18n !== undefined && !same(record.name_i18n ?? null, prepared.nameI18n ?? args.name_i18n))
1071
+ requestedChanges.push("name_i18n");
1072
+ if (args.relativeCode !== undefined && !same(record.relativeCode ?? null, args.relativeCode))
1073
+ requestedChanges.push("relativeCode");
1074
+ if (args.propertyEmpty !== undefined && !same(record.propertyEmpty ?? null, args.propertyEmpty))
1075
+ requestedChanges.push("propertyEmpty");
1076
+ if (args.options !== undefined) {
1077
+ for (const key of new Set([...Object.keys(currentOptions), ...Object.keys(prepared.options)]))
1078
+ if (!same(currentOptions[key] ?? null, prepared.options[key] ?? null))
1079
+ requestedChanges.push(`options.${key}`);
1080
+ }
1081
+ const mutableOptionKeys = new Set([
1082
+ // These are established field-row update surfaces (tickets 60/61 and the
1083
+ // condition pairing round). Everything else stays fail-closed: the
1084
+ // platform accepts many keys and silently drops them.
1085
+ "dictionaryData", "optionsSet", "mappings", "schemaCode", "queryCode", "displayField", "displayFieldType", "displayFieldFormat",
1086
+ "relativePropertyMatchCode", "relativePropertyCode", "linkMode", "showStyle", "searchFormula", "conditions",
1087
+ "requiredFormula", "readonlyCondition", "displayFormula", "readonlyFormula", "scale",
1088
+ ]);
1089
+ const changedOptionKeys = requestedChanges.filter((field) => field.startsWith("options.")).map((field) => field.slice("options.".length));
1090
+ const immutableOptionKeys = changedOptionKeys.filter((key) => !mutableOptionKeys.has(key));
1091
+ if (immutableOptionKeys.length > 0) {
1092
+ throw new ToolRefusalError({
1093
+ status: "refused",
1094
+ blocker: "field-options-immutable",
1095
+ schemaCode: args.schemaCode,
1096
+ code: args.code,
1097
+ fields: immutableOptionKeys.map((key) => `options.${key}`),
1098
+ mutableFields: [...mutableOptionKeys].map((key) => `options.${key}`),
1099
+ message: `field.update 不允许经此面修改 options.${immutableOptionKeys.join("、")};这类字段配置会被平台静默丢弃。可改白名单:${[...mutableOptionKeys].map((key) => `options.${key}`).join("、")}。关联结构(schemaCode/queryCode/displayField 等)请 field.delete + field.create 重建;条件请用字段条件配置。`,
1100
+ });
1101
+ }
1102
+ if (args.choices !== undefined && !same(choiceCodesOf(currentOptions) ?? [], [...args.choices]))
1103
+ requestedChanges.push("choices");
1104
+ if (requestedChanges.length === 0) {
1105
+ return {
1106
+ performed: { errcode: 0, unchanged: true },
1107
+ readback: {
1108
+ face: "draft",
1109
+ unchanged: true,
1110
+ changedFields: [],
1111
+ ...(record.name !== undefined ? { name: record.name } : {}),
1112
+ ...(record.name_i18n !== undefined ? { name_i18n: record.name_i18n } : {}),
1113
+ availableFields: ["name", "name_i18n", "options", "choices", "relativeCode", "propertyEmpty"],
1114
+ },
1115
+ };
1116
+ }
1030
1117
  const performed = await wireCall(session, { path: ENDPOINTS.fieldUpdate, method: "POST", body: fieldUpdateWireBody(update, prepared) });
1031
1118
  const readback = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } });
1032
1119
  const after = wireRows(readback.data).find((row) => (row.code ?? row.propertyCode) === args.code);
@@ -1038,7 +1125,9 @@ async function runFieldUpdate(session, args) {
1038
1125
  // failure never hides the field write (it is surfaced as formConditionSync.error).
1039
1126
  let formConditionSync;
1040
1127
  let formConditionSyncError;
1041
- if (effectiveOptions !== undefined && !prepared.isChildTable) {
1128
+ const conditionKeys = new Set(Object.keys(FORM_CONDITION_OPTION_KEYS));
1129
+ const conditionChanged = effectiveOptions !== undefined && [...conditionKeys].some((key) => !fieldValuesEqual((decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {})[key], prepared.options[key]));
1130
+ if (conditionChanged && !prepared.isChildTable) {
1042
1131
  const previous = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
1043
1132
  try {
1044
1133
  const sync = await syncFormConditionDataRules(session, args.schemaCode, args.code, previous, prepared.options);
@@ -1054,6 +1143,19 @@ async function runFieldUpdate(session, args) {
1054
1143
  // confirm the write landed — the readback only named the field. Field
1055
1144
  // options are the form-interaction contract (ADR 0005), so read them back.
1056
1145
  const storedOptions = after !== undefined ? parseFieldOptionsForReadback(after.options) : undefined;
1146
+ if (args.options !== undefined && storedOptions && typeof storedOptions === "object" && !Array.isArray(storedOptions)) {
1147
+ const missed = Object.keys(args.options).filter((key) => !same(storedOptions[key] ?? null, prepared.options[key] ?? null));
1148
+ if (missed.length > 0) {
1149
+ throw new ToolRefusalError({
1150
+ status: "refused",
1151
+ blocker: "field-options-immutable",
1152
+ schemaCode: args.schemaCode,
1153
+ code: args.code,
1154
+ fields: missed.map((key) => `options.${key}`),
1155
+ message: `field.update 的 options.${missed.join("、")} 经此面提交后未生效;options 属于不可经此面修改的字段配置。请按字段族契约使用 field.delete + field.create 重建,或走对应的表单/数据规则工具(不要把 DELIVERED 当成已生效)。`,
1156
+ });
1157
+ }
1158
+ }
1057
1159
  // r32(真机复现:field.update propertyEmpty:true 回 ok/DELIVERED,field.list --full
1058
1160
  // 与 model.inspect 都看不到该值):平台字段行读面(bizproperty/list 与
1059
1161
  // bizproperty/get)都不回 propertyEmpty,这是**只写属性**。不编造读回值:
@@ -1079,8 +1181,12 @@ async function runFieldUpdate(session, args) {
1079
1181
  face: "draft",
1080
1182
  impact,
1081
1183
  fieldPresent: after !== undefined,
1082
- ...(after?.name !== undefined ? { name: after.name } : {}),
1083
- ...(typeof after?.name_i18n === "string" ? { name_i18n: after.name_i18n } : {}),
1184
+ ...(after?.name !== undefined || prepared.name !== undefined || record.name !== undefined || record.propertyName !== undefined
1185
+ ? { name: after?.name ?? prepared.name ?? record.name ?? record.propertyName }
1186
+ : {}),
1187
+ ...(typeof after?.name_i18n === "string" || typeof prepared.nameI18n === "string" || typeof record.name_i18n === "string"
1188
+ ? { name_i18n: after?.name_i18n ?? prepared.nameI18n ?? record.name_i18n }
1189
+ : {}),
1084
1190
  ...(after?.type !== undefined || after?.propertyType !== undefined ? { type: after?.type ?? after?.propertyType } : {}),
1085
1191
  ...(hasReadBackPropertyEmpty ? { propertyEmpty: after?.propertyEmpty } : {}),
1086
1192
  committed: committedKeys,
@@ -1345,6 +1451,13 @@ export async function executeFieldPublish(session, args) {
1345
1451
  const preRead = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
1346
1452
  const prePublishedRows = wireRows(preRead.data);
1347
1453
  const prePublished = prePublishedRows.map(codeOf).filter((code) => code.length > 0);
1454
+ // Older servers accept a dangling relation row and only reveal it as the
1455
+ // misleading 300010 at model publish. Inspect the draft before POST so an
1456
+ // existing poisoned model gets the same field-specific diagnosis as create.
1457
+ const relationCandidates = await modelSchemaCodes(session);
1458
+ const relationFailure = relationPublishFailureMessage(wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } })).data), relationCandidates);
1459
+ if (relationFailure)
1460
+ throw new ToolRefusalError({ status: "refused", blocker: "relation-target-not-found", schemaCode: args.schemaCode, message: relationFailure, candidates: relationCandidates.slice(0, 5) });
1348
1461
  // r29e F10/F11:旧短路只看「code 在发布面存在」,改了 options 仍报
1349
1462
  // already-published + verification.ok(假绿)。再比草稿/发布 options 指纹;
1350
1463
  // 不一致就真发布。name/type 仍由 verifyFieldDetails 核对。
@@ -116,7 +116,7 @@ export const MODEL_LANE_CONTRACT = Object.freeze({
116
116
  }),
117
117
  configure_model: Object.freeze({
118
118
  scope: MODEL_SCOPE,
119
- description: "High-level: create a model with one field (fieldCode/fieldName/propertyType, legacy) or N fields (fields:[{code,name,propertyType,choices?,relativeCode?,options?}]) and drive it to the structurally usable state (model.create → field.create×N → model-scoped field.publish), optionally drafting and publishing the default form in the same package (publishForm:true → form.draft → form.publish) and configuring the default list view (`listview:{fieldCodes,...}` → listview.configure). Resumable: if the model already exists in the application tree, the model.create step is SKIPPED (reported as `skipped:\"model-exists\"`) and the field/publish steps still run — it does not abort on a pre-existing model. Family gates run per field BEFORE the write permission gate; for the form-only path use configure_form.",
119
+ description: "High-level: create a model with one field (fieldCode/fieldName/propertyType, legacy) or N fields (fields:[{code,name,propertyType,choices?,relativeCode?,options?}]) and drive it to the structurally usable state (model.create → field.create×N → model-scoped field.publish), optionally drafting and publishing the default form in the same package (publishForm:true → form.draft → form.publish) and configuring the default list view (`listview:{fieldCodes,...}` → listview.configure). Resumable: if the model already exists, the readback declares `created:false` and `mergedChanges`; `createOnly:true` refuses with `model-already-exists`. Model code is tenant-global and cannot be reused across applications. Family gates run per field BEFORE the write permission gate; for the form-only path use configure_form.",
120
120
  }),
121
121
  "model.deleteMany": Object.freeze({
122
122
  scope: MODEL_SCOPE,