@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.
@@ -46,7 +46,12 @@ function failureDetails(entry, args) {
46
46
  }
47
47
  return { hint: "平台回了无明细 50000,常见成因见 help omc-refusals;请用该行输入与已发布字段逐项核对" };
48
48
  }
49
- export function batchWarnings(argsList) {
49
+ export function batchWarnings(argsList, tool) {
50
+ // Runtime data validation belongs to the runtime-data lane, which has the
51
+ // published field metadata. The old generic `code` requirement fabricated
52
+ // ghost warnings for data.save (models commonly have no code field).
53
+ if (tool === "data.save")
54
+ return [];
50
55
  const warnings = [];
51
56
  const seenCodes = new Map();
52
57
  argsList.forEach((args, index) => {
@@ -143,6 +148,11 @@ export async function runBatchCall(options) {
143
148
  ? String(readback.authoritative.id)
144
149
  : undefined;
145
150
  const skippedMarker = performed?.skipped;
151
+ const applicationCode = typeof performed?.applicationCode === "string"
152
+ ? String(performed.applicationCode)
153
+ : typeof entry.result.readback?.applicationCode === "string"
154
+ ? String(entry.result.readback.applicationCode)
155
+ : undefined;
146
156
  return {
147
157
  index: entry.index,
148
158
  row: entry.index + 1,
@@ -150,12 +160,20 @@ export async function runBatchCall(options) {
150
160
  ok: entry.status === "ok",
151
161
  ...inputBusinessKey(options.argsList[entry.index] ?? {}),
152
162
  ...(bizObjectId !== undefined ? { bizObjectId } : {}),
163
+ ...(applicationCode !== undefined ? { applicationCode } : {}),
153
164
  ...(entry.status === "ok" ? {} : { status: String(entry.result.status ?? "failed"), blocker: entry.result.blocker ?? null }),
154
165
  ...(skippedMarker !== undefined ? { skipped: String(skippedMarker) } : {}),
155
166
  };
156
167
  });
157
168
  const entriesInline = entriesSummary.slice(0, ENTRIES_INLINE_LIMIT);
158
- const warnings = batchWarnings(options.argsList);
169
+ const laneWarnings = entries.flatMap((entry) => {
170
+ const warnings = entry.result.readback?.warnings;
171
+ return Array.isArray(warnings)
172
+ ? warnings.map((warning) => ({ ...warning, row: entry.index + 1 }))
173
+ : [];
174
+ });
175
+ const warnings = options.tool === "data.save" ? laneWarnings : batchWarnings(options.argsList, options.tool);
176
+ const applicationCode = entries.map((entry) => entry.result.performed?.applicationCode).find((value) => typeof value === "string");
159
177
  const warningsInline = warnings.slice(0, ENTRIES_INLINE_LIMIT);
160
178
  const detailOmitted = entriesSummary.length > entriesInline.length || warnings.length > warningsInline.length;
161
179
  const detailArtifact = detailOmitted
@@ -170,6 +188,7 @@ export async function runBatchCall(options) {
170
188
  executed,
171
189
  succeeded,
172
190
  failed,
191
+ ...(applicationCode !== undefined ? { applicationCode } : {}),
173
192
  ...(skipped > 0 ? { notExecuted: skipped } : {}),
174
193
  // 每条一行摘要(index/ok/bizObjectId)——不是完整信封,但足以回答
175
194
  // 「第 3 条建成了吗、它的 id 是什么」。失败项另有 failures 带完整原因。
package/dist/cli.js CHANGED
@@ -368,7 +368,7 @@ function parseDoctorStep(line) {
368
368
  function doctorAnchor() {
369
369
  const topics = listSkills().map((entry) => entry.name).join("、");
370
370
  return [
371
- "最小路径 omc next \"<目标>\"(目标→工具一步定位,不确定会明说)→ omc help omc-start-here → omc call discover_environment --env <env> --args '{}'(<env> 名以 doctor 的「环境=」或项目 .omc-env 为准,勿猜)→ omc capabilities / omc describe <tool>",
371
+ "最小路径 omc next \"<目标>\"(目标→工具一步定位,不确定会明说)→ omc help omc-start-here → omc call discover_environment --env <env> --args '{}'(<env> 名以 doctor 的「环境=」或项目 .omc-env 为准,勿猜;项目环境白名单按 cwd 解析,须在含 .omc-env 的目录内运行)→ omc capabilities / omc describe <tool>",
372
372
  `方法论 omc help <topic>(或 --topic <name>)--sections / --section "<标题>"(按节读,随包 skills/);topic:${topics}`,
373
373
  "大输出 超预算结果只回 artifactId/sha256/fetch;用 fetch 里的 omc artifact get <id>(可加 --jq <path>)取回,勿全量读入",
374
374
  // r29f F9:只读面不碰远端,但仍会本地落盘——写清,避免「零写入」核对误判。
@@ -534,7 +534,7 @@ async function cmdCall(store, args) {
534
534
  return emitTransportResult({ status: "ok", ...described, helpNote: "来自 omc describe;完整面用 omc describe <tool> [--full|--param|--example]" }, { request, maxOutputBytes });
535
535
  }
536
536
  }
537
- console.error("用法:omc call <tool> [--env <环境>] [--args '<json>' | --args - (stdin) | --args-file <配置根内路径(必须位于配置根内;跨目录临时文件用 --args -(stdin))>] [--extract <path>]... [--jq [path]] [--extract-keys] [--args-list <json数组> [--preflight]]");
537
+ console.error("用法:omc call <tool> [--env <环境>] [--args '<json>' | --args - (stdin) | --args-file <配置根内路径(必须位于配置根内;数组批量请改用 --args-list;跨目录临时文件用 --args -(stdin))>] [--extract <path>]... [--jq [path]] [--extract-keys] [--args-list <json数组>(最多 200 条;超出请分批或用 omc script 读文件) [--preflight]]");
538
538
  console.error("输出预算:--max-output-bytes 默认 65536(只约束最终 stdout 信封;读面 diet ~2048 独立外置 detailArtifactId);artifact get 内联上限 8192");
539
539
  console.error("写入:由环境 writable 决定(只读环境拒写);生产环境的写必须在 args 里带 confirmProduction:true。");
540
540
  return emit(toolName && toolName !== "help" ? "error" : "ok", {}, toolName && toolName !== "help" ? CLI_EXIT_CODES.invalidInput : 0);
@@ -632,18 +632,20 @@ async function cmdCall(store, args) {
632
632
  // 原来只在末尾附一句「若问题在引号转义…」,容易被当成补充说明;这里把它
633
633
  // 提成主建议,并给出 PS 可粘贴的两条命令。
634
634
  const looksLikeShellSplitting = /Unexpected (token|end of JSON)|Expected property name|position \d+/i.test(detail);
635
- const message = outOfRoot
636
- ? `--args-file 越界:${detail}。可执行做法:把参数文件放到 ${configRoot} 之下(如 ${join(configRoot, "args.json")}),再用 --args-file 引用;不要把参数文件放在系统临时目录(配置根之外一律拒绝)`
637
- : looksLikeShellSplitting
638
- ? `--args 不像合法 JSON(${detail})——这个形态通常是 shell 把内联 JSON 拆坏了(Windows PowerShell 的引号/转义规则),不是你写错了字段。改法二选一:① 把 JSON 写进 ${join(configRoot, "args.json")} 再用 --args-file;② \`omc call <tool> --args - \` 从 stdin 读(PS: \`'{"k":"v"}' | omc call <tool> --args -\`)。POSIX 文档里的单引号写法不能照抄到 PS`
639
- : `--args 必须是合法 JSON:${detail}。若问题在引号转义(Windows PowerShell),改用 --args-file <配置根内的路径> 或 --args -(stdin)`;
635
+ const message = argsFile !== undefined && /must be a JSON object/.test(detail)
636
+ ? `${detail};--args-file 只收单个 JSON 对象,批量数组请改用 --args-list(最多 200 条,可分批执行)`
637
+ : outOfRoot
638
+ ? `--args-file 越界:${detail}。可执行做法:把参数文件放到 ${configRoot} 之下(如 ${join(configRoot, "args.json")}),再用 --args-file 引用;不要把参数文件放在系统临时目录(配置根之外一律拒绝)`
639
+ : looksLikeShellSplitting
640
+ ? `--args 不像合法 JSON(${detail})——这个形态通常是 shell 把内联 JSON 拆坏了(Windows PowerShell 的引号/转义规则),不是你写错了字段。改法二选一:① 把 JSON 写进 ${join(configRoot, "args.json")} 再用 --args-file;② \`omc call <tool> --args - \` 从 stdin 读(PS: \`'{"k":"v"}' | omc call <tool> --args -\`)。POSIX 文档里的单引号写法不能照抄到 PS`
641
+ : `--args 必须是合法 JSON:${detail}。若问题在引号转义(Windows PowerShell),改用 --args-file <配置根内的路径> 或 --args -(stdin)`;
640
642
  console.error(message);
641
643
  // The MACHINE envelope must carry the same actionable text: agents read
642
644
  // stdout, and the old envelope only repeated the English detail.
643
645
  // Keep the REJECTED path on stderr only: the machine envelope must not leak
644
646
  // host layout (ticket 103), so it carries the root plus the bare file name.
645
647
  const rejectedName = typeof argsFile === "string" ? basename(resolve(configRoot, argsFile)) : "";
646
- writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: outOfRoot ? `--args-file 必须位于配置根内(配置根 ${configRoot}${rejectedName ? `;本次文件 ${rejectedName}` : ""})。免落盘替代:--args - 从 stdin 读 JSON(echo '<json>' | omc call <tool> --args -),或把文件放进配置根后再引用;限制是为了防参数文件携带凭据被收进会话产物` : detail, retryable: false, ...(outOfRoot ? { configurationRoot: configRoot } : {}) }));
648
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: outOfRoot ? `--args-file 必须位于配置根内(配置根 ${configRoot}${rejectedName ? `;本次文件 ${rejectedName}` : ""})。免落盘替代:--args - 从 stdin 读 JSON(echo '<json>' | omc call <tool> --args -),或把文件放进配置根后再引用;限制是为了防参数文件携带凭据被收进会话产物` : argsFile !== undefined && /must be a JSON object/.test(detail) ? `${detail};批量数组请改用 --args-list(最多 200 条,可分批执行)` : detail, retryable: false, ...(outOfRoot ? { configurationRoot: configRoot } : {}) }));
647
649
  return CLI_EXIT_CODES.invalidInput;
648
650
  }
649
651
  const registry = buildToolRegistry();
@@ -1624,6 +1626,7 @@ async function cmdGraphPull(environment, concurrency, transport) {
1624
1626
  identityFingerprint: data.meta.identityFingerprint,
1625
1627
  pulledAtIso: data.meta.pulledAtIso,
1626
1628
  scope: data.meta.scope,
1629
+ coverage: { applicationCodes: [data.meta.applicationCode] },
1627
1630
  freshness: data.gaps.length === 0 ? "complete" : "partial",
1628
1631
  scanned: { models: data.scanned.models, rules: data.scanned.rules, datarules: data.scanned.datarules, views: data.scanned.views, ...(data.scanned.parseFailures > 0 ? { parseFailures: data.scanned.parseFailures } : {}) },
1629
1632
  edges: edgesByKind,
@@ -2162,7 +2165,7 @@ async function main() {
2162
2165
  commandExitCode = CLI_EXIT_CODES.ok;
2163
2166
  });
2164
2167
  command.command("doctor").option("--environment <environment>").option("--env <environment>").option("--service <serviceCode>", "可选:体检单个业务集成服务的鉴权参数形状(掩码/占位/全 0 检出,WARN 非致命)").action(async (options) => { commandExitCode = await cmdDoctor(new ConfigStore(defaultPaths()), options.environment ?? options.env, options.service); });
2165
- command.command("call <tool>").option("--env <environment>").option("--environment <environment>").option("--args <json>").option("--args-file <path>", "从文件读参数 JSON。**必须位于配置根内**(跨目录/系统临时文件会被拒);免落盘用 `--args -`(stdin)").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象);逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").option("--keys", "与 artifact get 的 --keys 同义(= --extract-keys):列出本次结果 artifact 的顶层键与体积").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>", "最终 stdout 信封预算;默认 65536(投影/--jq 之后再判);读面 diet(~2048) 独立外置 detailArtifactId,抬高本参数不会把已 diet 的大表重新内联;artifact get 内联上限 8192").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
2168
+ command.command("call <tool>").option("--env <environment>", "环境名;项目白名单按当前 cwd 解析,须在含 .omc-env 的目录内运行").option("--environment <environment>").option("--args <json>").option("--args-file <path>", "从文件读参数 JSON。**必须位于配置根内**(数组批量请改用 --args-list;跨目录/系统临时文件会被拒);免落盘用 `--args -`(stdin)").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象,最多 200 条);超过上限请分批,或用 omc script 读文件。逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").option("--keys", "与 artifact get 的 --keys 同义(= --extract-keys):列出本次结果 artifact 的顶层键与体积").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>", "最终 stdout 信封预算;默认 65536(投影/--jq 之后再判);读面 diet(~2048) 独立外置 detailArtifactId,抬高本参数不会把已 diet 的大表重新内联;artifact get 内联上限 8192").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
2166
2169
  const rest = [tool, ...(options.env ? ["--env", options.env] : []), ...(options.environment ? ["--environment", options.environment] : []), ...(options.args !== undefined ? ["--args", options.args] : []), ...(options.argsFile !== undefined ? ["--args-file", options.argsFile] : []), ...(options.argsList !== undefined ? ["--args-list", options.argsList] : []), ...(options.preflight ? ["--preflight"] : []), ...(Array.isArray(options.extract) ? options.extract.flatMap((path) => ["--extract", path]) : []), ...(Array.isArray(options.jq) ? options.jq.flatMap((path) => path === true ? ["--jq"] : ["--jq", path]) : []), ...(options.extractKeys ? ["--extract-keys"] : []),
2167
2170
  ...(options.keys ? ["--keys"] : []), ...(options.maxOutputBytes !== undefined ? ["--max-output-bytes", options.maxOutputBytes] : []), ...(options.requestId ? ["--request-id", options.requestId] : []), ...(options.timeoutMs !== undefined ? ["--timeout-ms", options.timeoutMs] : []), ...(options.wait !== undefined ? ["--wait", options.wait] : []), ...(options.dryRun ? ["--dry-run"] : [])];
2168
2171
  commandExitCode = await cmdCall(new ConfigStore(defaultPaths()), rest);
@@ -2408,15 +2411,21 @@ async function main() {
2408
2411
  script: { lines: lines.length, skipped: parsed.skipped.length },
2409
2412
  }, 0);
2410
2413
  });
2411
- const rollbackCommand = command.command("rollback").description("配置回滚(git-revert 语义):list 列写时间线 / plan 预览恢复(diff+后续写覆盖警告)/ apply 执行恢复——恢复走完整写闸门并记 journal(回滚可再回滚)。接入全部配置写面(workflow/规则/数据规则/视图/表单草稿/在线JS/字典/字段/模型/应用/报表);publish 类生命周期推进不可逆(plan 会说明);journal 与 before 快照为本机边界(不跨机器)");
2414
+ const rollbackCommand = command.command("rollback").description("配置回滚(git-revert 语义):list 列写时间线 / plan 预览恢复(diff+后续写覆盖警告)/ apply 执行恢复——恢复走完整写闸门并记 journal(回滚可再回滚)。apply 支持 --wait <毫秒> 有界排队;list/plan 为只读面不接写锁。接入全部配置写面(workflow/规则/数据规则/视图/表单草稿/在线JS/字典/字段/模型/应用/报表);publish 类生命周期推进不可逆(plan 会说明);journal 与 before 快照为本机边界(不跨机器)");
2412
2415
  rollbackCommand.command("list").option("--env <environment>").option("--environment <environment>").option("--target <substr>").option("--tool <name>").option("--limit <n>").action(async (options) => {
2413
2416
  commandExitCode = await withTransport(options, "rollback list", (transport) => cmdRollbackList({ environment: options.env ?? options.environment, target: options.target, tool: options.tool, limit: options.limit }, transport));
2414
2417
  });
2415
2418
  rollbackCommand.command("plan <ref>").option("--env <environment>").option("--environment <environment>").action(async (ref, options) => {
2416
2419
  commandExitCode = await withTransport(options, "rollback plan", (transport) => cmdRollbackPlan(ref, { environment: options.env ?? options.environment }, transport));
2417
2420
  });
2418
- rollbackCommand.command("apply <ref>").option("--env <environment>").option("--environment <environment>").option("--confirm-production").action(async (ref, options) => {
2419
- commandExitCode = await withTransport(options, "rollback apply", (transport) => cmdRollbackApply(ref, { environment: options.env ?? options.environment, confirmProduction: options.confirmProduction === true }, transport));
2421
+ rollbackCommand.command("apply <ref>").option("--env <environment>").option("--environment <environment>").option("--confirm-production").option("--wait <milliseconds>", "写锁冲突时的有界排队毫秒数(默认 0=立刻拒绝)").action(async (ref, options) => {
2422
+ const waitMs = options.wait === undefined ? 0 : Number(options.wait);
2423
+ if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 600_000) {
2424
+ console.error("--wait must be an integer number of milliseconds from 0 to 600000");
2425
+ commandExitCode = CLI_EXIT_CODES.invalidInput;
2426
+ return;
2427
+ }
2428
+ commandExitCode = await withTransport(options, "rollback apply", (transport) => cmdRollbackApply(ref, { environment: options.env ?? options.environment, confirmProduction: options.confirmProduction === true, waitMs }, transport));
2420
2429
  });
2421
2430
  const artifactCommand = command.command("artifact");
2422
2431
  artifactCommand.command("prune").option("--older-than <duration>", "删除超过该时长的读回产物(如 7d/12h/30m)").option("--max-bytes <bytes>", "产物目录预算;超出时按最旧优先删除").option("--json").action((options) => {
@@ -59,7 +59,12 @@ export function prepareFieldChange(input) {
59
59
  assertFormConditions(raw);
60
60
  const options = ensureRelationDisplayFieldOptions(propertyType, raw);
61
61
  const displayCode = relationDisplayFieldCode(propertyType, options);
62
- return { propertyType, isChildTable, options, ...(displayCode ? { displayCode } : {}) };
62
+ const optionRelationTarget = typeof options.schemaCode === "string" ? options.schemaCode.trim() : "";
63
+ const normalizedRelation = (propertyType === FIELD_PROPERTY_TYPES.WORK_SHEET || propertyType === FIELD_PROPERTY_TYPES.MULT_WORK_SHEET) &&
64
+ optionRelationTarget.length > 0 && !(typeof input.relativeCode === "string" && input.relativeCode.trim().length > 0)
65
+ ? { relationTarget: "options.schemaCode→relativeCode" }
66
+ : undefined;
67
+ return { propertyType, isChildTable, options, ...(displayCode ? { displayCode } : {}), ...(normalizedRelation ? { normalized: normalizedRelation } : {}) };
63
68
  }
64
69
  /** The single platform wire body for a field create. */
65
70
  export function fieldChangeWireBody(input, prepared) {
@@ -74,6 +79,7 @@ export function fieldChangeWireBody(input, prepared) {
74
79
  ? { defaultValue: null, subSchema: prepared.subSchema }
75
80
  : { defaultValue: "", ...(prepared.displayCode ? { relativePropertyCode: prepared.displayCode } : {}), options: JSON.stringify(prepared.options) }),
76
81
  ...(input.relativeCode ? { relativeCode: input.relativeCode } : {}),
82
+ ...(!input.relativeCode && prepared.normalized ? { relativeCode: String(prepared.options.schemaCode) } : {}),
77
83
  };
78
84
  }
79
85
  /**
@@ -270,6 +270,13 @@ export const FIELD_TYPE_SYNONYMS = Object.freeze({
270
270
  });
271
271
  /** A "did you mean" hint for a family name that is actually a Chinese concept. */
272
272
  export function fieldTypeHint(type) {
273
+ const commonAliases = {
274
+ SINGLE_TEXT: "SHORT_TEXT",
275
+ NUMERIC: "NUMERICAL",
276
+ };
277
+ const alias = commonAliases[type.toUpperCase()];
278
+ if (alias)
279
+ return `「${type}」不是族名;你要找的可能是 ${alias}`;
273
280
  for (const [concept, family] of Object.entries(FIELD_TYPE_SYNONYMS))
274
281
  if (type.includes(concept) || concept.includes(type))
275
282
  return `「${type}」看起来是概念名而非族名;最接近的是 ${family}`;
@@ -281,6 +288,63 @@ export function fieldTypeHint(type) {
281
288
  return `「${type}」不是族名;你要找的可能是 ${closest}`;
282
289
  return undefined;
283
290
  }
291
+ /**
292
+ * Validate a model-relation target before bizproperty/create. The platform
293
+ * accepts the row and only fails at model publish (300010), which leaves the
294
+ * whole model poisoned. Callers supply the already-read model list so this
295
+ * helper remains usable by both field entry points without owning transport.
296
+ */
297
+ export function assertRelationTargetExists(args, candidates) {
298
+ const raw = args.propertyType;
299
+ const type = typeof raw === "number" && Number.isInteger(raw)
300
+ ? raw
301
+ : typeof raw === "string" && raw in FIELD_PROPERTY_TYPES ? FIELD_PROPERTY_TYPES[raw] : undefined;
302
+ if (type !== FIELD_PROPERTY_TYPES.WORK_SHEET && type !== FIELD_PROPERTY_TYPES.MULT_WORK_SHEET)
303
+ return;
304
+ const optionsCode = typeof args.options?.schemaCode === "string" ? args.options.schemaCode.trim() : "";
305
+ const relativeCode = typeof args.relativeCode === "string" ? args.relativeCode.trim() : "";
306
+ const target = optionsCode || relativeCode;
307
+ // Some offline/fake transports do not expose a model-list face. An empty
308
+ // candidate set is therefore "unknown", not proof of absence; live model
309
+ // lists are non-empty and get the strict pre-write gate.
310
+ if (!target || candidates.length === 0 || candidates.includes(target))
311
+ return;
312
+ const visibleCandidates = [...candidates].sort().slice(0, 5);
313
+ throw new ToolRefusalError({
314
+ status: "refused",
315
+ blocker: "relation-target-not-found",
316
+ relationTarget: target,
317
+ candidates: visibleCandidates,
318
+ message: `关联字段 ${String(args.code ?? "(未命名)")} 的关联目标 ${target} 不存在;已在写入前拒绝。可选模型:${visibleCandidates.length > 0 ? visibleCandidates.join("、") : "(无)"}`,
319
+ });
320
+ }
321
+ /** Construct the field-specific explanation when an older platform leaks 300010. */
322
+ export function relationPublishFailureMessage(fields, candidates) {
323
+ for (const field of fields) {
324
+ let options;
325
+ if (field.options && typeof field.options === "object" && !Array.isArray(field.options))
326
+ options = field.options;
327
+ else if (typeof field.options === "string") {
328
+ try {
329
+ const parsed = JSON.parse(field.options);
330
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
331
+ options = parsed;
332
+ }
333
+ catch { /* malformed options will be reported by the platform */ }
334
+ }
335
+ const raw = field.propertyType ?? field.type;
336
+ const type = typeof raw === "number" ? raw : typeof raw === "string" && raw in FIELD_PROPERTY_TYPES ? FIELD_PROPERTY_TYPES[raw] : undefined;
337
+ if (type !== FIELD_PROPERTY_TYPES.WORK_SHEET && type !== FIELD_PROPERTY_TYPES.MULT_WORK_SHEET)
338
+ continue;
339
+ const target = typeof options?.schemaCode === "string" ? options.schemaCode.trim() : typeof field.relativeCode === "string" ? field.relativeCode.trim() : "";
340
+ if (typeof options?.schemaCode === "string" && options.schemaCode.trim() && (!field.relativeCode || String(field.relativeCode).trim() === "")) {
341
+ return `字段 ${String(field.code ?? "(未知)")} 的关联目标 ${options.schemaCode} 仅写在 options.schemaCode;发布面不解析该位置(平台原始 errcode 300010「数据模型编码不能为空!」)。请重建/归一化为顶层 relativeCode=${options.schemaCode}`;
342
+ }
343
+ if (target && candidates.length > 0 && !candidates.includes(target))
344
+ return `字段 ${String(field.code ?? "(未知)")} 的关联目标 ${target} 不存在(平台原始 errcode 300010「数据模型编码不能为空!」);可选模型:${candidates.slice(0, 5).join("、")}`;
345
+ }
346
+ return undefined;
347
+ }
284
348
  /** Case/underscore-insensitive closest match over the family list (shared helper, see closest-match.ts). */
285
349
  function closestFamilyName(type) {
286
350
  return closestMatch(type, Object.keys(FIELD_PROPERTY_TYPES));
package/dist/graph.js CHANGED
@@ -52,12 +52,19 @@ export const DEFAULT_PULL_CONCURRENCY = 16;
52
52
  export const MAX_PULL_CONCURRENCY = 32;
53
53
  /** 索引超过这个天数就提示刷新(配置类查询,1 天足够新)。 */
54
54
  export const STALE_INDEX_DAYS = 1;
55
+ export const STALE_AFTER_MINUTES = STALE_INDEX_DAYS * 24 * 60;
55
56
  export function indexAgeDays(pulledAtIso, now = Date.now()) {
56
57
  const pulled = Date.parse(pulledAtIso);
57
58
  if (Number.isNaN(pulled))
58
59
  return 0;
59
60
  return Math.max(0, Math.floor((now - pulled) / 86_400_000));
60
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
+ }
61
68
  export class GraphIndexAbsentError extends Error {
62
69
  constructor(message) {
63
70
  super(message);
@@ -404,6 +411,7 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
404
411
  identityFingerprint: identity,
405
412
  environmentName: session.environmentName,
406
413
  applicationCode,
414
+ coverage: { applicationCodes: [applicationCode] },
407
415
  ...(scope !== undefined ? { applicationResolution: applicationResolutionOf(scope) } : {}),
408
416
  pulledAtIso,
409
417
  },
@@ -611,11 +619,14 @@ function openGraphIndex() {
611
619
  }
612
620
  const pulledAtIso = meta.pulledAtIso ?? "";
613
621
  const ageDays = indexAgeDays(pulledAtIso);
622
+ const ageMinutes = indexAgeMinutes(pulledAtIso);
623
+ const coverage = { applicationCodes: meta.applicationCode ? [meta.applicationCode] : [] };
614
624
  return {
615
625
  db,
616
626
  summary: {
617
627
  environmentName: meta.environmentName ?? "",
618
628
  applicationCode: meta.applicationCode ?? "",
629
+ coverage,
619
630
  // Persisted as JSON by the writer; a legacy index (pre-0.5.4) has no
620
631
  // such key, so this stays optional rather than failing the read.
621
632
  ...(meta.applicationResolution ? { applicationResolution: parseApplicationResolution(meta.applicationResolution) } : {}),
@@ -623,6 +634,8 @@ function openGraphIndex() {
623
634
  pulledAtIso,
624
635
  freshness: gapCount === 0 ? "complete" : "partial",
625
636
  ageDays,
637
+ ageMinutes,
638
+ staleAfterMinutes: STALE_AFTER_MINUTES,
626
639
  // r31a-F16:旧索引 + complete 标签 = 静默错答。给可直接照抄的刷新命令,
627
640
  // 并明确「complete 只指扫描无缺口」。
628
641
  ...(ageDays >= STALE_INDEX_DAYS
@@ -696,7 +709,7 @@ export function queryGraphReferences(kind, code) {
696
709
  ? {
697
710
  note: (gapped
698
711
  ? "零命中,但索引存在扫描缺口——不可作为「无引用」证明;先 omc graph pull 重建再查"
699
- : "零命中=仅 scanned 面(规则/数据规则/视图)没有引用它;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定可安全删除") +
712
+ : `零命中=仅 scanned 面(规则/数据规则/视图)没有引用它;本索引覆盖应用:${summary.coverage.applicationCodes.join("、") || "(未知)"},不代表其他应用没有引用;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定可安全删除`) +
700
713
  (zeroHitKindHint(db, kind, code, "references") ?? ""),
701
714
  }
702
715
  : {}),
@@ -719,6 +732,10 @@ export function readGraphStatus() {
719
732
  dbBytes: statSync(dbPath).size,
720
733
  dbPath,
721
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,
722
739
  gaps,
723
740
  };
724
741
  }
@@ -733,7 +750,7 @@ export function readGraphStatus() {
733
750
  * a prefix match (`引用*`), which is also what makes CJK usable: unicode61
734
751
  * stores a CJK run as one token, so a full-run query hits only by prefix.
735
752
  */
736
- export function searchGraphRules(term, limit = 20) {
753
+ export function searchGraphRules(term, limit = 20, offset = 0) {
737
754
  const { db, summary } = openGraphIndex();
738
755
  try {
739
756
  const tokens = term.trim().split(/\s+/).filter((token) => token !== "");
@@ -748,8 +765,8 @@ export function searchGraphRules(term, limit = 20) {
748
765
  const rows = db
749
766
  .prepare(`SELECT r.schema_code, r.rule_code, r.name, r.enabled, r.status
750
767
  FROM rules_fts JOIN rules r ON r.rowid = rules_fts.rowid
751
- WHERE rules_fts MATCH ? ORDER BY bm25(rules_fts), r.schema_code, r.rule_code LIMIT ?`)
752
- .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);
753
770
  const refsFor = db.prepare("SELECT kind, code FROM edges WHERE schema_code = ? AND rule_code = ? ORDER BY kind, code");
754
771
  matches = rows.map((row) => {
755
772
  const refs = { service: [], model: [], field: [] };
@@ -767,8 +784,15 @@ export function searchGraphRules(term, limit = 20) {
767
784
  });
768
785
  const searchEntity = (countSql, rowsSql, params, map) => {
769
786
  const total = Number(db.prepare(countSql).get(...params).n);
770
- const rows = db.prepare(rowsSql).all(...params, limit);
771
- return { count: rows.length, total, items: rows.map(map), ...(rows.length < total ? { truncated: true } : {}) };
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
+ };
772
796
  };
773
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 }));
774
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 }));
@@ -789,7 +813,7 @@ export function searchGraphRules(term, limit = 20) {
789
813
  // 实测摩擦(2026-10-30,zlt测试):中文子串 0 命中后没有下一步提示,
790
814
  // 调用方在「换个词再试」里打转。把两个出口都写进 note:CJK 词首
791
815
  // 通配 + 找引用走 references/impact。
792
- : "没有命中规则。索引只覆盖规则的 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>。`,
793
817
  }
794
818
  : {}),
795
819
  ...(gapped ? { gapWarning: `索引有 ${summary.gaps.count} 个扫描缺口;命中列表可能不完整` } : {}),
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, assertFieldFamilyAllowed, assertSelectionChoicesAllowed, } 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 核对,不要把自动行当成重复写入。",
@@ -637,6 +659,15 @@ async function loadFieldRowForUpdate(session, schemaCode, code) {
637
659
  const record = rows.find((row) => (row.code ?? row.propertyCode) === code);
638
660
  if (!record)
639
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
+ }
640
671
  return record;
641
672
  }
642
673
  async function countModelDataRows(session, schemaCode) {
@@ -1031,6 +1062,58 @@ async function runFieldUpdate(session, args) {
1031
1062
  ...(args.propertyEmpty !== undefined ? { propertyEmpty: args.propertyEmpty } : {}),
1032
1063
  };
1033
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
+ }
1034
1117
  const performed = await wireCall(session, { path: ENDPOINTS.fieldUpdate, method: "POST", body: fieldUpdateWireBody(update, prepared) });
1035
1118
  const readback = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } });
1036
1119
  const after = wireRows(readback.data).find((row) => (row.code ?? row.propertyCode) === args.code);
@@ -1042,7 +1125,9 @@ async function runFieldUpdate(session, args) {
1042
1125
  // failure never hides the field write (it is surfaced as formConditionSync.error).
1043
1126
  let formConditionSync;
1044
1127
  let formConditionSyncError;
1045
- 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) {
1046
1131
  const previous = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
1047
1132
  try {
1048
1133
  const sync = await syncFormConditionDataRules(session, args.schemaCode, args.code, previous, prepared.options);
@@ -1058,6 +1143,19 @@ async function runFieldUpdate(session, args) {
1058
1143
  // confirm the write landed — the readback only named the field. Field
1059
1144
  // options are the form-interaction contract (ADR 0005), so read them back.
1060
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
+ }
1061
1159
  // r32(真机复现:field.update propertyEmpty:true 回 ok/DELIVERED,field.list --full
1062
1160
  // 与 model.inspect 都看不到该值):平台字段行读面(bizproperty/list 与
1063
1161
  // bizproperty/get)都不回 propertyEmpty,这是**只写属性**。不编造读回值:
@@ -1083,8 +1181,12 @@ async function runFieldUpdate(session, args) {
1083
1181
  face: "draft",
1084
1182
  impact,
1085
1183
  fieldPresent: after !== undefined,
1086
- ...(after?.name !== undefined ? { name: after.name } : {}),
1087
- ...(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
+ : {}),
1088
1190
  ...(after?.type !== undefined || after?.propertyType !== undefined ? { type: after?.type ?? after?.propertyType } : {}),
1089
1191
  ...(hasReadBackPropertyEmpty ? { propertyEmpty: after?.propertyEmpty } : {}),
1090
1192
  committed: committedKeys,
@@ -1349,6 +1451,13 @@ export async function executeFieldPublish(session, args) {
1349
1451
  const preRead = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
1350
1452
  const prePublishedRows = wireRows(preRead.data);
1351
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) });
1352
1461
  // r29e F10/F11:旧短路只看「code 在发布面存在」,改了 options 仍报
1353
1462
  // already-published + verification.ok(假绿)。再比草稿/发布 options 指纹;
1354
1463
  // 不一致就真发布。name/type 仍由 verifyFieldDetails 核对。