@stalfh233/omc-cli 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +304 -3
  3. package/coverage/m1-coverage-manifest-v1.json +1278 -0
  4. package/dist/approval-token.js +102 -0
  5. package/dist/args.js +25 -0
  6. package/dist/artifacts.js +119 -0
  7. package/dist/bench/call-face-eval.js +256 -0
  8. package/dist/bench/context-attribution.js +151 -0
  9. package/dist/bench/discovery-cost-eval.js +230 -0
  10. package/dist/bench/driver.js +81 -0
  11. package/dist/bench/evals.js +236 -0
  12. package/dist/bench/fake-http-server.js +65 -0
  13. package/dist/bench/instrument.js +87 -0
  14. package/dist/bench/intent-face-eval.js +343 -0
  15. package/dist/bench/run.js +166 -0
  16. package/dist/bench/scenario.js +343 -0
  17. package/dist/bench/types.js +76 -0
  18. package/dist/bi-wire.js +41 -0
  19. package/dist/bizservice-config.js +581 -0
  20. package/dist/call.js +153 -0
  21. package/dist/capability-absences.js +23 -0
  22. package/dist/capability-overview.js +492 -0
  23. package/dist/capability-shape.js +154 -0
  24. package/dist/cli-contract.js +70 -0
  25. package/dist/cli-output.js +73 -0
  26. package/dist/cli.js +1418 -0
  27. package/dist/code-rules.js +69 -0
  28. package/dist/command-transport.js +155 -0
  29. package/dist/config-store.js +195 -0
  30. package/dist/context.js +21 -0
  31. package/dist/contract-consistency.js +66 -0
  32. package/dist/contract-resources.js +62 -0
  33. package/dist/coverage-consistency.js +62 -0
  34. package/dist/coverage-registry.js +76 -0
  35. package/dist/coverage.js +130 -0
  36. package/dist/data-list-filter.js +114 -0
  37. package/dist/discovery.js +390 -0
  38. package/dist/endpoints.js +154 -0
  39. package/dist/environment-policy.js +26 -0
  40. package/dist/execution-metadata.js +1092 -0
  41. package/dist/fake/app.js +45 -0
  42. package/dist/fake/b2-registration.js +594 -0
  43. package/dist/fake/businessrule.js +242 -0
  44. package/dist/fake/datarule.js +72 -0
  45. package/dist/fake/dictionary.js +108 -0
  46. package/dist/fake/environment.js +30 -0
  47. package/dist/fake/field.js +167 -0
  48. package/dist/fake/form.js +101 -0
  49. package/dist/fake/index.js +121 -0
  50. package/dist/fake/list-view.js +289 -0
  51. package/dist/fake/model.js +136 -0
  52. package/dist/fake/online-js.js +18 -0
  53. package/dist/fake/report.js +253 -0
  54. package/dist/fake/routes.js +47 -0
  55. package/dist/fake/rule-lifecycle.js +37 -0
  56. package/dist/fake/runtime-data.js +367 -0
  57. package/dist/fake/state.js +67 -0
  58. package/dist/fake/workflow.js +364 -0
  59. package/dist/field-change.js +200 -0
  60. package/dist/field-families.js +896 -0
  61. package/dist/form-layout.js +111 -0
  62. package/dist/form-support.js +821 -0
  63. package/dist/goal-routes.js +468 -0
  64. package/dist/governed-execution.js +87 -0
  65. package/dist/human-summary.js +212 -0
  66. package/dist/identity.js +62 -0
  67. package/dist/intent/baseline.js +57 -0
  68. package/dist/intent/capabilities/bizservice.js +274 -0
  69. package/dist/intent/capabilities/businessrule.js +493 -0
  70. package/dist/intent/capabilities/datarule.js +187 -0
  71. package/dist/intent/capabilities/field.js +545 -0
  72. package/dist/intent/capabilities/form.js +136 -0
  73. package/dist/intent/capabilities/index.js +64 -0
  74. package/dist/intent/capabilities/listview.js +157 -0
  75. package/dist/intent/capabilities/model.js +100 -0
  76. package/dist/intent/capabilities/onlinejs.js +108 -0
  77. package/dist/intent/capabilities/report.js +355 -0
  78. package/dist/intent/capabilities/workflow.js +458 -0
  79. package/dist/intent/capability.js +6 -0
  80. package/dist/intent/cli.js +91 -0
  81. package/dist/intent/compare.js +56 -0
  82. package/dist/intent/compiler.js +79 -0
  83. package/dist/intent/dsl.js +129 -0
  84. package/dist/intent/plan-file.js +63 -0
  85. package/dist/intent/readback.js +65 -0
  86. package/dist/intent/schema.js +158 -0
  87. package/dist/intent/validation.js +30 -0
  88. package/dist/intent/yaml.js +315 -0
  89. package/dist/json-column.js +68 -0
  90. package/dist/lanes/app-contract.js +95 -0
  91. package/dist/lanes/app-coverage.js +16 -0
  92. package/dist/lanes/app.js +174 -0
  93. package/dist/lanes/apply-changes.js +231 -0
  94. package/dist/lanes/b2-registration-contract.js +292 -0
  95. package/dist/lanes/b2-registration-coverage.js +48 -0
  96. package/dist/lanes/b2-registration.js +1187 -0
  97. package/dist/lanes/businessrule-contract.js +232 -0
  98. package/dist/lanes/businessrule-coverage.js +16 -0
  99. package/dist/lanes/businessrule.js +221 -0
  100. package/dist/lanes/contract-support.js +65 -0
  101. package/dist/lanes/coverage-declaration.js +9 -0
  102. package/dist/lanes/datarule-contract.js +155 -0
  103. package/dist/lanes/datarule-coverage.js +19 -0
  104. package/dist/lanes/datarule-protocol.js +308 -0
  105. package/dist/lanes/datarule.js +818 -0
  106. package/dist/lanes/dictionary-contract.js +78 -0
  107. package/dist/lanes/dictionary-coverage.js +24 -0
  108. package/dist/lanes/dictionary.js +235 -0
  109. package/dist/lanes/environment-contract.js +70 -0
  110. package/dist/lanes/environment-coverage.js +14 -0
  111. package/dist/lanes/environment.js +163 -0
  112. package/dist/lanes/field-contract.js +223 -0
  113. package/dist/lanes/field-coverage.js +27 -0
  114. package/dist/lanes/field.js +374 -0
  115. package/dist/lanes/form-contract.js +97 -0
  116. package/dist/lanes/form-coverage.js +16 -0
  117. package/dist/lanes/form.js +185 -0
  118. package/dist/lanes/lane-ids.js +34 -0
  119. package/dist/lanes/list-view-contract.js +175 -0
  120. package/dist/lanes/list-view-coverage.js +20 -0
  121. package/dist/lanes/list-view-shapes.js +1207 -0
  122. package/dist/lanes/list-view.js +578 -0
  123. package/dist/lanes/meta-contract.js +85 -0
  124. package/dist/lanes/meta.js +255 -0
  125. package/dist/lanes/model-contract.js +168 -0
  126. package/dist/lanes/model-coverage.js +20 -0
  127. package/dist/lanes/model.js +986 -0
  128. package/dist/lanes/online-js-contract.js +99 -0
  129. package/dist/lanes/online-js-coverage.js +28 -0
  130. package/dist/lanes/online-js.js +127 -0
  131. package/dist/lanes/report-contract.js +144 -0
  132. package/dist/lanes/report-coverage.js +21 -0
  133. package/dist/lanes/report.js +476 -0
  134. package/dist/lanes/rule-graph.js +1846 -0
  135. package/dist/lanes/rule-lifecycle-contract.js +92 -0
  136. package/dist/lanes/rule-lifecycle-coverage.js +20 -0
  137. package/dist/lanes/rule-lifecycle.js +176 -0
  138. package/dist/lanes/runtime-data-contract.js +264 -0
  139. package/dist/lanes/runtime-data-coverage.js +25 -0
  140. package/dist/lanes/runtime-data.js +1054 -0
  141. package/dist/lanes/workflow-contract.js +223 -0
  142. package/dist/lanes/workflow-coverage.js +40 -0
  143. package/dist/lanes/workflow.js +1813 -0
  144. package/dist/online-js-layout.js +58 -0
  145. package/dist/online-js-source.js +276 -0
  146. package/dist/package-tool.js +51 -0
  147. package/dist/package.js +73 -0
  148. package/dist/plan.js +73 -0
  149. package/dist/read.js +144 -0
  150. package/dist/redact.js +28 -0
  151. package/dist/rule-support.js +587 -0
  152. package/dist/runtime-support.js +134 -0
  153. package/dist/server.js +92 -0
  154. package/dist/session-manager.js +30 -0
  155. package/dist/session.js +149 -0
  156. package/dist/skills.js +112 -0
  157. package/dist/support.js +98 -0
  158. package/dist/tool-types.js +127 -0
  159. package/dist/tools.js +59 -0
  160. package/dist/usage-log.js +197 -0
  161. package/dist/wire.js +287 -0
  162. package/dist/workflow-support.js +99 -0
  163. package/dist/write-lease.js +26 -0
  164. package/dist/write-lock.js +109 -0
  165. package/dist/write.js +176 -0
  166. package/dist/zip.js +156 -0
  167. package/docs/tool-surface-map.md +42 -0
  168. package/package.json +71 -6
  169. package/skills/omc-acceptance-criteria.md +47 -0
  170. package/skills/omc-business-configuration.md +257 -0
  171. package/skills/omc-capabilities.md +197 -0
  172. package/skills/omc-capability-scouting.md +55 -0
  173. package/skills/omc-config-draft-review.md +171 -0
  174. package/skills/omc-five-piece-flow.md +35 -0
  175. package/skills/omc-glossary.md +86 -0
  176. package/skills/omc-refusals.md +110 -0
  177. package/skills/omc-requirement-analysis.md +161 -0
  178. package/skills/omc-requirement-vocabulary.md +51 -0
  179. package/skills/omc-start-here.md +82 -0
  180. package/skills/omc-tool-selection.md +119 -0
  181. package/skills/omc-write-hazards.md +87 -0
@@ -0,0 +1,986 @@
1
+ import { z } from "zod";
2
+ import { ENDPOINTS } from "../endpoints.js";
3
+ import { assertFieldFamilyAllowed, choicesArg, fieldOptionsArg, nameI18nJson, propertyTypeArg, relationTargetOf, relativeCodeArg, SYSTEM_FIELD_CODES, subFieldArg, } from "../field-families.js";
4
+ import { fieldChangeWireBody, prepareFieldChange, sameFieldChange } from "../field-change.js";
5
+ import { executeFormDraftWrite, executeFormPublishWrite } from "../form-support.js";
6
+ import { validateBusinessCode } from "../code-rules.js";
7
+ import { writeSummary } from "../human-summary.js";
8
+ import { capabilityArg, environmentArg } from "../args.js";
9
+ import { governedRead, withoutReadbackCache } from "../read.js";
10
+ import { parseToolInput } from "../tool-types.js";
11
+ import { flattenTreeRows, resolveApplicationCode } from "../support.js";
12
+ import { writeTool } from "../write.js";
13
+ import { registerPackageTool } from "../package-tool.js";
14
+ import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
15
+ import { wireCall, wireRows, WireError } from "../wire.js";
16
+ import { MODEL_CREATE_BODY_DEFAULTS, MODEL_LANE_CONTRACT } from "./model-contract.js";
17
+ const MODEL_LANE = MODEL_LANE_CONTRACT.lane;
18
+ const MODEL_TOOLS = MODEL_LANE_CONTRACT.tools;
19
+ /**
20
+ * Ticket 68 — locate a model's app-tree node across applications. The app
21
+ * tree row id is the AppFunction id that `apppackage/physically_delete`
22
+ * requires (`removeBizModel(AppFunction.id)`); a biz-model record id is NOT
23
+ * interchangeable and the platform answers 601003 业务模型不存在 for it. The
24
+ * default/profile application is queried first; on a miss every other
25
+ * application's tree is scanned (the model may live in a non-default app), and
26
+ * the caller refuses `model-application-unresolved` rather than guessing an id.
27
+ */
28
+ export async function resolveModelTreeNode(session, schemaCode, requestedApplication) {
29
+ const primary = await resolveApplicationCode(session, requestedApplication).catch(() => "");
30
+ const treeOf = async (appCode) => appCode ? flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode } })).data)) : [];
31
+ if (primary) {
32
+ const rows = await treeOf(primary);
33
+ const node = rows.find((row) => row.type === "BizModel" && row.code === schemaCode);
34
+ if (node)
35
+ return { node, applicationCode: primary, treeRows: rows };
36
+ }
37
+ const apps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data);
38
+ for (const app of apps) {
39
+ const appCode = String(app.appCode ?? app.code ?? "");
40
+ if (!appCode || appCode === primary)
41
+ continue;
42
+ const rows = await treeOf(appCode);
43
+ const node = rows.find((row) => row.type === "BizModel" && row.code === schemaCode);
44
+ if (node)
45
+ return { node, applicationCode: appCode, treeRows: rows };
46
+ }
47
+ return { applicationCode: primary, treeRows: [] };
48
+ }
49
+ /**
50
+ * T-11 (round 2) — the runtime data-title expression is `Summary[]`, NOT a
51
+ * human template string. `BizObjectTitleSetter.getSummaryValue`
52
+ * (launcher-8.6.26 :140-160) runs `JsonUtility.toList(summaryExpression,
53
+ * Summary.class)` over `Summary{int isDataItem; String code; String param}`
54
+ * where isDataItem 1 = form field, 2 = query field. Writing a raw template
55
+ * like "{field}" stores INVALID JSON, so every later data.save/data.submit on
56
+ * that model throws at title resolution and surfaces only as a bare
57
+ * `50000 服务异常` (live-verified, extremely hard to diagnose).
58
+ *
59
+ * Normalization: canonical Summary[] JSON is validated and re-serialized;
60
+ * a PURE `{field}` placeholder template (the live repro shape) is converted to
61
+ * `[{isDataItem:1,code:"field"}]`; anything else (literal text, malformed
62
+ * JSON) is refused fail-closed so the model can never be poisoned.
63
+ */
64
+ function validSummaryEntry(entry, index) {
65
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
66
+ throw new ToolRefusalError({ status: "refused", blocker: "model-summary-invalid", message: `summary[${index}] 必须是对象 {{isDataItem,code[,param]}}` });
67
+ const { isDataItem, code, param } = entry;
68
+ if (isDataItem !== 1 && isDataItem !== 2)
69
+ throw new ToolRefusalError({ status: "refused", blocker: "model-summary-invalid", message: `summary[${index}].isDataItem 必须是 1(表单字段)或 2(查询字段)` });
70
+ if (typeof code !== "string" || code.trim().length === 0)
71
+ throw new ToolRefusalError({ status: "refused", blocker: "model-summary-invalid", message: `summary[${index}].code 必须是非空字段编码` });
72
+ return { isDataItem, code, ...(typeof param === "string" && param.length > 0 ? { param } : {}) };
73
+ }
74
+ /**
75
+ * T-11 — whether a STORED data-title face is a valid `Summary[]` (a poisoned
76
+ * model — one written by the pre-fix raw-template path — is not, and its
77
+ * runtime title resolution throws).
78
+ */
79
+ export function isCanonicalSummary(stored) {
80
+ try {
81
+ const parsed = JSON.parse(stored);
82
+ return (Array.isArray(parsed) &&
83
+ parsed.length > 0 &&
84
+ parsed.every((entry) => entry !== null &&
85
+ typeof entry === "object" &&
86
+ !Array.isArray(entry) &&
87
+ (entry.isDataItem === 1 || entry.isDataItem === 2) &&
88
+ typeof entry.code === "string" &&
89
+ entry.code.length > 0));
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ export function normalizeSummaryInput(raw) {
96
+ const trimmed = raw.trim();
97
+ if (trimmed.startsWith("[")) {
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(trimmed);
101
+ }
102
+ catch (error) {
103
+ throw new ToolRefusalError({ status: "refused", blocker: "model-summary-invalid", message: `summary 起始为 "[" 但不是合法 JSON:${error.message}。线上形状是 Summary[](如 [{\"isDataItem\":1,\"code\":\"<字段>\"}])` });
104
+ }
105
+ if (!Array.isArray(parsed) || parsed.length === 0)
106
+ throw new ToolRefusalError({ status: "refused", blocker: "model-summary-invalid", message: 'summary 必须是至少一项的 Summary[],如 [{"isDataItem":1,"code":"<字段>"}]' });
107
+ return JSON.stringify(parsed.map(validSummaryEntry));
108
+ }
109
+ const tokens = trimmed.match(/\{[A-Za-z0-9_]+\}/g);
110
+ if (tokens && tokens.join("") === trimmed) {
111
+ return JSON.stringify(tokens.map((token) => ({ isDataItem: 1, code: token.slice(1, -1) })));
112
+ }
113
+ throw new ToolRefusalError({
114
+ status: "refused",
115
+ blocker: "model-summary-invalid",
116
+ message: 'summary 线上形状是 Summary[](平台按 JsonUtility.toList 反序列化)——不支持字面文本。' +
117
+ '纯 {字段} 占位串会被转换为 [{isDataItem:1,code:"字段"}];含字面文本的模板无法表示(旧工具把它原样入库会毒化模型:之后该模型所有 data.save/submit 恒 50000)。' +
118
+ '请传 Summary[] JSON(isDataItem 1=表单字段、2=查询字段)或纯 {字段} 串',
119
+ });
120
+ }
121
+ export function registerModelLane(tools) {
122
+ // ---- read faces (round 10 ergonomics) --------------------------------
123
+ // `model.list` and `model.inspect` close the survey's top gap: enumerating
124
+ // models, and reading ONE model's whole design-time configuration in a single
125
+ // call instead of a 9–16 call crawl across field.form.listview.rule.datarule.
126
+ tools.push(defineTool({
127
+ name: "model.list",
128
+ layer: "low",
129
+ kind: "read",
130
+ lane: MODEL_LANE,
131
+ scope: MODEL_TOOLS["model.list"].scope,
132
+ description: MODEL_TOOLS["model.list"].description,
133
+ inputSchema: {
134
+ environment: environmentArg,
135
+ applicationCode: z.string().optional().describe("应用 code;省略用配置/默认应用"),
136
+ filter: z.string().optional().describe("子串过滤:code 或 name 含该词才保留"),
137
+ capability: capabilityArg,
138
+ },
139
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
140
+ const applicationCode = await resolveApplicationCode(session, args.applicationCode);
141
+ const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
142
+ const all = tree
143
+ .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code)
144
+ .map((row) => ({ code: row.code, name: row.name ?? row.text ?? null }));
145
+ const needle = args.filter?.trim();
146
+ const models = needle ? all.filter((model) => String(model.code).includes(needle) || String(model.name ?? "").includes(needle)) : all;
147
+ // `filterApplied` disambiguates an empty result: it distinguishes "the
148
+ // filter ran and matched nothing" from "the filter was not applied"
149
+ // (dogfood R1: count:0 alone gave no signal).
150
+ return { applicationCode, count: models.length, totalBeforeFilter: all.length, filterApplied: needle !== undefined && needle !== "", ...(needle ? { filter: needle } : {}), models };
151
+ }, { target: `model.list:${args.applicationCode ?? ""}:${args.filter ?? ""}` }),
152
+ }));
153
+ const siblings = tools;
154
+ tools.push(defineTool({
155
+ name: "model.inspect",
156
+ layer: "high",
157
+ kind: "read",
158
+ lane: MODEL_LANE,
159
+ scope: MODEL_TOOLS["model.inspect"].scope,
160
+ description: MODEL_TOOLS["model.inspect"].description,
161
+ inputSchema: {
162
+ environment: environmentArg,
163
+ schemaCode: z.string().min(1).optional().describe("模型 code(给一个 code 读全配置;或用 schemaCodes 批量)"),
164
+ schemaCodes: z.array(z.string().min(1)).optional().describe("一次读多个模型的配置摘要(等价于对每个 code 各调一次 model.inspect,但只花一次往返;返回 {models:[…]})"),
165
+ all: z.boolean().optional().describe("默认只返业务字段与非系统规则;true 含系统字段/平台默认规则"),
166
+ capability: capabilityArg,
167
+ },
168
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
169
+ const all = args.all === true;
170
+ const codes = args.schemaCodes && args.schemaCodes.length > 0 ? args.schemaCodes : args.schemaCode ? [args.schemaCode] : [];
171
+ if (codes.length === 0)
172
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "model.inspect", message: "需要 schemaCode(单个)或 schemaCodes(数组,批量)" });
173
+ const face = async (name, toolArgs) => {
174
+ const tool = siblings.find((entry) => entry.name === name);
175
+ if (!tool)
176
+ return undefined;
177
+ try {
178
+ const parsed = parseToolInput(tool, { ...toolArgs, environment: args.environment });
179
+ // Composition must see the real payload: if an identical standalone
180
+ // read already primed the window-diet cache, the sibling would answer
181
+ // with `{ unchanged:true, detailFile }` and this face would come back
182
+ // empty. Bypass the short-circuit for the nested reads only.
183
+ const result = (await withoutReadbackCache(ctx, async () => (await tool.handler(ctx, parsed, tool))));
184
+ return result;
185
+ }
186
+ catch {
187
+ return undefined; // a single face being unreachable must not sink the whole inspect
188
+ }
189
+ };
190
+ const inspectOne = async (schemaCode) => {
191
+ const fieldFace = await face("field.list", { schemaCode, published: true, ...(all ? { all: true } : {}) });
192
+ const fieldRows = Array.isArray(fieldFace?.fields) ? fieldFace.fields : [];
193
+ const fields = fieldRows
194
+ .filter((field) => all || !SYSTEM_FIELD_CODES.has(String(field.code)))
195
+ .map((field) => ({ code: field.code, type: field.type, ...(field.typeName !== undefined ? { typeName: field.typeName } : {}), name: field.name, ...(field.conditions ? { conditions: field.conditions } : {}) }));
196
+ const formFace = await face("form.get", { schemaCode });
197
+ const form = formFace && formFace.present !== undefined
198
+ ? { present: formFace.present === true, published: formFace.published === true, controlCount: Array.isArray(formFace.draftControlCodes) ? formFace.draftControlCodes.length : 0 }
199
+ : undefined;
200
+ const listviewFace = await face("listview.get", { schemaCode });
201
+ const listviewColumns = Array.isArray(listviewFace?.columns) ? listviewFace.columns : [];
202
+ // Round 13: the default view is platform-generated and lists the system
203
+ // columns (createdTime/creater/name/sequenceNo/…). In a design-time survey
204
+ // they are noise the caller must filter out; `all:true` restores them (a
205
+ // user CAN author a system column into a view, so this is opt-out, not a
206
+ // delete). Same SYSTEM_FIELD_CODES set field.list uses.
207
+ const visibleColumns = all ? listviewColumns : listviewColumns.filter((column) => !SYSTEM_FIELD_CODES.has(String(column.propertyCode)));
208
+ const listview = listviewFace && listviewFace.columns
209
+ ? { code: listviewFace.code ?? schemaCode, columnCount: visibleColumns.length, columns: visibleColumns.map((column) => ({ propertyCode: column.propertyCode, propertyType: column.propertyType, visible: column.visible })) }
210
+ : undefined;
211
+ const ruleFace = await face("rule.list", { schemaCode });
212
+ const ruleRows = Array.isArray(ruleFace?.rules) ? ruleFace.rules : [];
213
+ // All rules are kept: the platform pre-creates trigger rows (Create/Update/
214
+ // …) but user logic is AUTHORED INTO them, so `defaultRule` cannot be used
215
+ // to hide "system" rules without dropping authored ones. `defaultRule` is
216
+ // reported per rule so a caller can decide.
217
+ const rules = ruleRows.map((rule) => ({ code: rule.code, name: rule.name, enabled: rule.enabled, status: rule.status, ruleVersion: rule.ruleVersion, defaultRule: rule.defaultRule }));
218
+ const dataruleFace = await face("datarule.list", { schemaCode });
219
+ const dataruleRows = Array.isArray(dataruleFace?.rules) ? dataruleFace.rules : [];
220
+ const datarules = dataruleRows.map((rule) => ({ propertyCode: rule.propertyCode, propertyCodes: rule.propertyCodes, dataRuleType: rule.dataRuleType, name: rule.name, checkType: rule.checkType }));
221
+ // Workflow summary: use the PUBLISHED face for the `published` flag. The
222
+ // draft face's templateType is ALWAYS "DRAFT" (even for a published
223
+ // workflow — draft and published are separate faces), so deriving
224
+ // `published` from get_draft reported published:false forever (live round
225
+ // 10: an agent's flow read as published:false while get_published was
226
+ // templateType:PUBLISH). Activities come from whichever face exists.
227
+ const draftFace = await face("workflow.get_draft", { schemaCode });
228
+ const workflowCode = draftFace?.workflowSummary?.workflowCode ?? draftFace?.workflowCode;
229
+ const publishedFace = workflowCode ? await face("workflow.get_published", { schemaCode }) : undefined;
230
+ const draftSummary = draftFace?.workflowSummary;
231
+ const pubSummary = publishedFace?.workflowSummary;
232
+ const summary = pubSummary ?? draftSummary;
233
+ const workflow = draftFace?.exists === false || (draftFace === undefined && publishedFace === undefined)
234
+ ? (draftFace && draftFace.schemaCode ? { exists: false } : undefined)
235
+ : {
236
+ workflowCode: summary?.workflowCode ?? workflowCode,
237
+ published: publishedFace?.published === true || pubSummary?.templateType === "PUBLISH",
238
+ activities: Array.isArray(summary?.activities) ? summary.activities.map((activity) => ({ code: activity.code, type: activity.type, name: activity.name, participant: activity.participant })) : [],
239
+ edges: summary?.edges,
240
+ };
241
+ return {
242
+ schemaCode,
243
+ fields,
244
+ form,
245
+ listview,
246
+ rules,
247
+ datarules,
248
+ workflow,
249
+ hint: "model.inspect 聚合设计时六面(fields/form/listview/rules/datarules/workflow,摘要)。字段 options 细节用 field.list --code;规则图用 rule.get schemaCode+ruleCode;运行数据用 data.list(不在此面)。",
250
+ };
251
+ };
252
+ const one = await inspectOne(codes[0]);
253
+ if (codes.length === 1)
254
+ return one;
255
+ const models = [one];
256
+ for (const code of codes.slice(1))
257
+ models.push(await inspectOne(code));
258
+ return { schemaCodes: codes, count: models.length, models, hint: "每个元素是 model.inspect 对单个模型的摘要;批量只花一次往返。" };
259
+ }, {
260
+ target: `model.inspect:${(args.schemaCodes ?? [args.schemaCode]).join(",")}:${args.all === true ? "all" : "biz"}`,
261
+ compact: (result) => {
262
+ // Batch shape (`{models:[…]}`) is already a summary; only a single-model
263
+ // inspect needs the window-diet projection.
264
+ if (Array.isArray(result.models))
265
+ return result;
266
+ return {
267
+ schemaCode: result.schemaCode,
268
+ fieldCodes: (result.fields ?? []).map((field) => field.code),
269
+ form: result.form,
270
+ listview: result.listview ? { code: result.listview.code, columnCount: result.listview.columnCount } : undefined,
271
+ ruleCodes: (result.rules ?? []).map((rule) => rule.code),
272
+ datarules: result.datarules,
273
+ workflow: result.workflow ? { workflowCode: result.workflow.workflowCode, published: result.workflow.published } : undefined,
274
+ hint: result.hint,
275
+ };
276
+ },
277
+ }),
278
+ }));
279
+ writeTool(tools, "model.create", MODEL_LANE, MODEL_TOOLS["model.create"].scope, MODEL_TOOLS["model.create"].description, { name: z.string().min(1), code: z.string().min(1), folderId: z.string().optional(), applicationCode: z.string().optional(), modelType: z.enum(["LIST", "TREE"]).default("LIST") }, async (session, args) => {
280
+ const applicationCode = await resolveApplicationCode(session, args.applicationCode);
281
+ const existing = await resolveModelTreeNode(session, args.code, args.applicationCode);
282
+ if (existing.node) {
283
+ throw new ToolRefusalError({
284
+ status: "refused",
285
+ blocker: "model-create-duplicate",
286
+ schemaCode: args.code,
287
+ applicationCode: existing.applicationCode,
288
+ message: `模型 ${args.code} 已存在于应用 ${existing.applicationCode} 的应用树中;create 拒绝重复 POST,不能把既有模型证明为本次创建结果`,
289
+ });
290
+ }
291
+ let folderId = args.folderId;
292
+ let resolvedFrom = args.folderId ? "explicit" : undefined;
293
+ if (!folderId) {
294
+ const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
295
+ const folder = tree.find((row) => row.type !== "BizModel" && typeof row.id === "string");
296
+ if (!folder)
297
+ throw new WireError("business", "no folder found for model creation", 302001);
298
+ folderId = String(folder.id);
299
+ resolvedFrom = "auto-default-folder";
300
+ }
301
+ const performed = await wireCall(session, {
302
+ path: ENDPOINTS.modelCreate,
303
+ method: "POST",
304
+ body: {
305
+ appCode: applicationCode,
306
+ name: args.name,
307
+ parentId: folderId,
308
+ modelType: args.modelType ?? MODEL_CREATE_BODY_DEFAULTS.modelType,
309
+ bizSchemaCode: args.code,
310
+ icon: MODEL_CREATE_BODY_DEFAULTS.icon,
311
+ name_i18n: nameI18nJson(args.name),
312
+ pcAble: MODEL_CREATE_BODY_DEFAULTS.pcAble,
313
+ mobileAble: MODEL_CREATE_BODY_DEFAULTS.mobileAble,
314
+ options: MODEL_CREATE_BODY_DEFAULTS.options,
315
+ },
316
+ });
317
+ const present = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data)).some((row) => row.type === "BizModel" && row.code === args.code);
318
+ return { performed: { errcode: performed.errcode, folderId, resolvedFrom, applicationCode }, readback: { present } };
319
+ });
320
+ /** model.delete writeTool extra arg: a session-aware dry-run that scans inbound references (no write). */
321
+ async function modelDeleteDryRun(session, args) {
322
+ const record = await (async () => {
323
+ try {
324
+ return (await wireCall(session, { path: ENDPOINTS.modelGet, query: { schemaCode: args.schemaCode } })).data ?? null;
325
+ }
326
+ catch (error) {
327
+ if (error instanceof WireError && error.kind === "business")
328
+ return null;
329
+ throw error;
330
+ }
331
+ })();
332
+ if (record === null)
333
+ return { wouldChange: false, absent: true, mayBeReferenced: false, reason: "model-already-absent" };
334
+ const resolved = await resolveModelTreeNode(session, args.schemaCode, args.applicationCode);
335
+ const siblings = resolved.treeRows
336
+ .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== args.schemaCode)
337
+ .map((row) => ({ schemaCode: String(row.code) }));
338
+ const referencing = [];
339
+ for (const sibling of siblings) {
340
+ const fields = [];
341
+ for (const isPublish of ["false", "true"]) {
342
+ for (const row of wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: sibling.schemaCode, isPublish } })).data)) {
343
+ if (relationTargetOf(row) !== args.schemaCode)
344
+ continue;
345
+ const code = row.code ?? row.propertyCode;
346
+ if (typeof code === "string" && !fields.includes(code))
347
+ fields.push(code);
348
+ }
349
+ }
350
+ if (fields.length > 0)
351
+ referencing.push({ schemaCode: sibling.schemaCode, fields });
352
+ }
353
+ const nodeResolved = resolved.node !== undefined;
354
+ return {
355
+ // The dry-run now performs the same inbound-reference scan the executor
356
+ // does, so "wouldChange:true" is never contradicted by a later refusal
357
+ // (dogfood R3). `mayBeReferenced` is the honest preview.
358
+ wouldChange: !nodeResolved || referencing.length === 0,
359
+ mayBeReferenced: referencing.length > 0,
360
+ referencingModels: referencing,
361
+ treeNodeResolved: nodeResolved,
362
+ applicationCode: resolved.applicationCode,
363
+ ...(referencing.length > 0
364
+ ? { blocker: "model-referenced-by", nextAction: "先删引用方模型或先删其关联字段,再删本模型(依赖倒序)" }
365
+ : !nodeResolved
366
+ ? { blocker: "model-application-unresolved", nextAction: "显式传 applicationCode 或先 discover_environment 确认归属应用" }
367
+ : {}),
368
+ };
369
+ }
370
+ writeTool(tools, "model.delete", MODEL_LANE, MODEL_TOOLS["model.delete"].scope, MODEL_TOOLS["model.delete"].description, { schemaCode: z.string().min(1), applicationCode: z.string().optional() }, async (session, args) => {
371
+ // authoritative model record (bizmodels/get_all); the app-tree node id
372
+ // may lag behind and is cleaned best-effort.
373
+ const readRecord = async () => {
374
+ try {
375
+ const result = await wireCall(session, { path: ENDPOINTS.modelGet, query: { schemaCode: args.schemaCode } });
376
+ return result.data ?? null;
377
+ }
378
+ catch (error) {
379
+ if (error instanceof WireError && error.kind === "business")
380
+ return null;
381
+ throw error;
382
+ }
383
+ };
384
+ const record = await readRecord();
385
+ // Ticket 68: resolve the app-tree node across applications (the tree row
386
+ // id IS the AppFunction id physically_delete needs). Never fall back to
387
+ // the biz-model record id — the platform answers 601003 业务模型不存在
388
+ // for it (removeBizModel looks the id up as an AppFunction id).
389
+ const resolved = await resolveModelTreeNode(session, args.schemaCode, args.applicationCode);
390
+ const node = resolved.node;
391
+ const applicationCode = resolved.applicationCode;
392
+ const treeRows = async () => applicationCode ? flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data)) : [];
393
+ if (!node) {
394
+ if (record === null)
395
+ return { performed: { skipped: "already-absent" }, readback: { absent: true } };
396
+ throw new ToolRefusalError({
397
+ status: "refused",
398
+ blocker: "model-application-unresolved",
399
+ schemaCode: args.schemaCode,
400
+ message: `${args.schemaCode} 的业务模型记录仍存在,但在任何应用树中都找不到它的节点(AppFunction)——不能删:physically_delete 需要 AppFunction id,传入业务模型记录 id 会被平台以 601003 业务模型不存在拒绝(不猜测 id)。请显式传 applicationCode(模型所属应用),或用 discover_environment 确认该模型的归属应用后再删。`,
401
+ });
402
+ }
403
+ const targetId = node.id;
404
+ const treeRowList = resolved.treeRows.length > 0 ? resolved.treeRows : await treeRows();
405
+ // Ticket 23 inbound-reference precheck: the platform's sync face answers
406
+ // errcode 0 while the async deleteFunctions dispatcher refuses with
407
+ // "xxx被模型 yyy 引用,不能删除" — surface that refusal at call time with
408
+ // the referencing models, before any write reaches the environment.
409
+ const siblings = treeRowList
410
+ .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== args.schemaCode)
411
+ .map((row) => ({ schemaCode: String(row.code), name: typeof row.name === "string" && row.name ? row.name : String(row.code) }));
412
+ const referencingModels = [];
413
+ for (const sibling of siblings) {
414
+ const referencedFields = [];
415
+ for (const isPublish of ["false", "true"]) {
416
+ const listed = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: sibling.schemaCode, isPublish } });
417
+ for (const row of wireRows(listed.data)) {
418
+ if (relationTargetOf(row) !== args.schemaCode)
419
+ continue;
420
+ const code = row.code ?? row.propertyCode;
421
+ if (typeof code === "string" && !referencedFields.some((field) => field.code === code)) {
422
+ referencedFields.push({ code, type: row.type ?? row.propertyType });
423
+ }
424
+ }
425
+ }
426
+ if (referencedFields.length > 0)
427
+ referencingModels.push({ schemaCode: sibling.schemaCode, name: sibling.name, fields: referencedFields });
428
+ }
429
+ if (referencingModels.length > 0) {
430
+ throw new ToolRefusalError({
431
+ status: "refused",
432
+ blocker: "model-referenced-by",
433
+ schemaCode: args.schemaCode,
434
+ referencingModels,
435
+ message: `${args.schemaCode} 被模型 ${referencingModels.map((model) => model.schemaCode).join("、")} 的关联字段引用,不能删除(服务端 deleteFunctions 的异步拒绝已提前到调用时)。依赖倒序清理:先删引用方模型(或先删其关联字段),再删被引用模型;引用方物理删除的逐列异步级联需 40-60s,完成后再删本模型。`,
436
+ });
437
+ }
438
+ const performed = await wireCall(session, {
439
+ path: ENDPOINTS.appPackagePhysicallyDelete,
440
+ method: "POST",
441
+ body: [{ id: targetId, code: args.schemaCode, type: "BizModel" }],
442
+ });
443
+ // Both the model record and the app-tree node are eventually consistent
444
+ // (the per-column async cascade takes 3-4s per column, 40-60s for a
445
+ // 12-column model). Poll the authoritative record face for verified
446
+ // absence (~60s budget), then report the tree-node state as a pending
447
+ // hint with explicit wait-and-recheck guidance instead of a failure.
448
+ let absent = false;
449
+ for (let attempt = 0; attempt < 16 && !absent; attempt += 1) {
450
+ if (attempt > 0)
451
+ await new Promise((resolve) => setTimeout(resolve, Math.min(1000 * attempt, 4000)));
452
+ absent = (await readRecord()) === null;
453
+ }
454
+ const treeNodePending = (await treeRows()).some((row) => row.type === "BizModel" && row.code === args.schemaCode);
455
+ return {
456
+ performed: { errcode: performed.errcode, deletedId: targetId, applicationCode },
457
+ readback: {
458
+ absent,
459
+ treeNodePending,
460
+ ...(absent
461
+ ? {}
462
+ : {
463
+ nextAction: "wait-and-recheck",
464
+ note: "异步逐列级联实测 40-60s:absent:false + treeNodePending:true 表示删除级联仍在进行,不是失败。等待约 60s 后重跑 model.delete(幂等,already-absent 短路)复核;若刚删除引用方模型,其级联可能仍在拆除关联列,必须等它完成后再删本模型。",
465
+ }),
466
+ },
467
+ };
468
+ }, undefined, modelDeleteDryRun);
469
+ writeTool(tools, "model.deleteMany", MODEL_LANE, MODEL_TOOLS["model.deleteMany"].scope, MODEL_TOOLS["model.deleteMany"].description, { schemaCodes: z.array(z.string().min(1)).min(1), applicationCode: z.string().optional() }, async (session, args) => {
470
+ // Platform evidence: physically_delete takes the WHOLE list in ONE
471
+ // request (AppPackageController.java:1424 @RequestBody
472
+ // List<AppDeleteParamVO>, 8.6.26 decompiled-full). The batch tool sends
473
+ // every existing model in that single request after prechecking each
474
+ // target's inbound references — a referenced model refuses the whole
475
+ // batch with per-target evidence and ZERO side effects.
476
+ const readRecord = async (schemaCode) => {
477
+ try {
478
+ const result = await wireCall(session, { path: ENDPOINTS.modelGet, query: { schemaCode } });
479
+ return result.data ?? null;
480
+ }
481
+ catch (error) {
482
+ if (error instanceof WireError && error.kind === "business")
483
+ return null;
484
+ throw error;
485
+ }
486
+ };
487
+ const applicationCode = await resolveApplicationCode(session, args.applicationCode).catch(() => "");
488
+ const targets = [...new Set(args.schemaCodes)];
489
+ const alreadyAbsent = [];
490
+ const entries = [];
491
+ const unresolved = [];
492
+ // Ticket 68: resolve each model's app-tree node across applications; the
493
+ // tree id (AppFunction id) is what physically_delete needs. Collect the
494
+ // resolved trees for the sibling scan (cross-application references too).
495
+ const resolvedTrees = [];
496
+ const seenTrees = new Set();
497
+ const appsToScan = new Set();
498
+ for (const code of targets) {
499
+ const resolved = await resolveModelTreeNode(session, code, args.applicationCode);
500
+ appsToScan.add(resolved.applicationCode);
501
+ if (!seenTrees.has(resolved.applicationCode) && resolved.treeRows.length > 0) {
502
+ seenTrees.add(resolved.applicationCode);
503
+ resolvedTrees.push(...resolved.treeRows);
504
+ }
505
+ if (resolved.node?.id) {
506
+ entries.push({ id: resolved.node.id, code, type: "BizModel" });
507
+ }
508
+ else if ((await readRecord(code)) === null) {
509
+ alreadyAbsent.push(code);
510
+ }
511
+ else {
512
+ unresolved.push(code);
513
+ }
514
+ }
515
+ if (unresolved.length > 0) {
516
+ throw new ToolRefusalError({
517
+ status: "refused",
518
+ blocker: "model-application-unresolved",
519
+ unresolved,
520
+ message: `批量删除被拒(零副作用):${unresolved.join("、")} 的业务模型记录仍存在但在任何应用树中都找不到节点(AppFunction)——physically_delete 需要 AppFunction id,不能拿业务模型记录 id 猜测。请显式传 applicationCode,或先用 discover_environment 确认归属应用。`,
521
+ });
522
+ }
523
+ const treeRowList = resolvedTrees;
524
+ // Per-target inbound-reference precheck (ticket 23 semantics, scanned
525
+ // once per sibling across all batch targets). Siblings INSIDE the batch
526
+ // are being deleted together and therefore do not block.
527
+ const batchSet = new Set(targets);
528
+ const siblings = treeRowList
529
+ .filter((row) => row.type === "BizModel" && typeof row.code === "string" && !batchSet.has(String(row.code)))
530
+ .map((row) => ({ schemaCode: String(row.code), name: typeof row.name === "string" && row.name ? row.name : String(row.code) }));
531
+ const blockedTargets = [];
532
+ for (const sibling of siblings) {
533
+ const hitsByTarget = new Map();
534
+ for (const isPublish of ["false", "true"]) {
535
+ const listed = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: sibling.schemaCode, isPublish } });
536
+ for (const row of wireRows(listed.data)) {
537
+ const target = relationTargetOf(row);
538
+ if (target === undefined || !batchSet.has(target))
539
+ continue;
540
+ const code = row.code ?? row.propertyCode;
541
+ if (typeof code !== "string")
542
+ continue;
543
+ const hits = hitsByTarget.get(target) ?? [];
544
+ if (!hits.some((field) => field.code === code))
545
+ hits.push({ code, type: row.type ?? row.propertyType });
546
+ hitsByTarget.set(target, hits);
547
+ }
548
+ }
549
+ for (const [target, fields] of hitsByTarget) {
550
+ const blocked = blockedTargets.find((entry) => entry.schemaCode === target) ?? { schemaCode: target, referencingModels: [] };
551
+ blocked.referencingModels.push({ schemaCode: sibling.schemaCode, name: sibling.name, fields });
552
+ if (!blockedTargets.includes(blocked))
553
+ blockedTargets.push(blocked);
554
+ }
555
+ }
556
+ if (blockedTargets.length > 0) {
557
+ throw new ToolRefusalError({
558
+ status: "refused",
559
+ blocker: "model-referenced-by",
560
+ blockedTargets,
561
+ message: `批量删除被拒(零副作用):${blockedTargets.map((blocked) => `${blocked.schemaCode} 被模型 ${blocked.referencingModels.map((model) => model.schemaCode).join("、")} 的关联字段引用`).join(";")}。依赖倒序清理:把引用方模型一并纳入 schemaCodes(或先删其关联字段);引用方物理删除的逐列异步级联需 40-60s,完成后再删被引用模型。`,
562
+ });
563
+ }
564
+ if (entries.length === 0) {
565
+ return { performed: { skipped: "already-absent", alreadyAbsent }, readback: { perCode: Object.fromEntries(targets.map((code) => [code, { absent: true }])), allAbsent: true } };
566
+ }
567
+ const performed = await wireCall(session, {
568
+ path: ENDPOINTS.appPackagePhysicallyDelete,
569
+ method: "POST",
570
+ body: entries,
571
+ });
572
+ // Per-model absence polling on the authoritative record face (the async
573
+ // per-column cascade takes 40-60s per model); pending codes carry the
574
+ // same wait-and-recheck guidance as model.delete.
575
+ const absentSet = new Set();
576
+ for (let attempt = 0; attempt < 16 && absentSet.size < entries.length; attempt += 1) {
577
+ if (attempt > 0)
578
+ await new Promise((resolve) => setTimeout(resolve, Math.min(1000 * attempt, 4000)));
579
+ for (const entry of entries) {
580
+ if (absentSet.has(entry.code))
581
+ continue;
582
+ if ((await readRecord(entry.code)) === null)
583
+ absentSet.add(entry.code);
584
+ }
585
+ }
586
+ const afterTree = (await Promise.all([...appsToScan]
587
+ .filter((appCode) => appCode)
588
+ .map(async (appCode) => flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode } })).data))))).flat();
589
+ const perCode = {};
590
+ for (const code of targets) {
591
+ if (alreadyAbsent.includes(code)) {
592
+ perCode[code] = { absent: true };
593
+ continue;
594
+ }
595
+ const absent = absentSet.has(code);
596
+ const treeNodePending = afterTree.some((row) => row.type === "BizModel" && row.code === code);
597
+ perCode[code] = absent ? { absent, ...(treeNodePending ? { treeNodePending } : {}) } : { absent, treeNodePending, nextAction: "wait-and-recheck", note: "异步逐列级联实测 40-60s:absent:false 表示该模型的删除级联仍在进行,不是失败。等待约 60s 后重跑 model.deleteMany(幂等,already-absent 短路)复核。" };
598
+ }
599
+ const allAbsent = targets.every((code) => perCode[code]?.absent === true);
600
+ return {
601
+ performed: { errcode: performed.errcode, deletedIds: entries.map((entry) => entry.id), alreadyAbsent, requested: targets, applicationCode },
602
+ readback: {
603
+ perCode,
604
+ allAbsent,
605
+ ...(allAbsent ? {} : { nextAction: "wait-and-recheck", note: "部分模型仍在异步级联中;按 perCode 逐项查看,等待约 60s 后重跑(幂等)。" }),
606
+ },
607
+ };
608
+ }, (args) => {
609
+ const codes = args.schemaCodes;
610
+ if (!Array.isArray(codes) || codes.length === 0 || new Set(codes).size !== codes.length || codes.some((code) => typeof code !== "string" || code.length === 0)) {
611
+ throw new ToolRefusalError({ status: "refused", blocker: "model-delete-many-invalid", message: "schemaCodes 必须是非空、去重的模型 code 数组" });
612
+ }
613
+ });
614
+ writeTool(tools, "model.setSummary", MODEL_LANE, MODEL_TOOLS["model.setSummary"].scope, MODEL_TOOLS["model.setSummary"].description, {
615
+ schemaCode: z.string().min(1),
616
+ summary: z.string().min(1).describe('数据标题表达式,线上存储形状是 Summary[] JSON(isDataItem 1=表单字段、2=查询字段),如 [{"isDataItem":1,"code":"subject"}];也接受纯 {字段} 占位串(自动转换为 Summary[])。含字面文本的模板无法表示会被拒(model-summary-invalid)'),
617
+ }, async (session, args) => {
618
+ // 8.6.26 BizSchemaController.updateSummary (decompiled :700-736):
619
+ // POST bizmodels/update_summary {schemaCode, summary}; the platform
620
+ // itself requires a non-empty summary (validateNotEmpty) and answers
621
+ // 300014 BIZ_SCHEMA_NOT_PUBLISHED for a missing schema. Readback is the
622
+ // sibling GET get_summary face.
623
+ const summary = normalizeSummaryInput(args.summary);
624
+ const performed = await wireCall(session, {
625
+ path: ENDPOINTS.modelUpdateSummary,
626
+ method: "POST",
627
+ body: { schemaCode: args.schemaCode, summary },
628
+ });
629
+ const readback = await wireCall(session, { path: ENDPOINTS.modelGetSummary, query: { schemaCode: args.schemaCode } });
630
+ const stored = readback.data ?? "";
631
+ const storedValid = isCanonicalSummary(stored);
632
+ return {
633
+ performed: { errcode: performed.errcode },
634
+ readback: {
635
+ summary: stored,
636
+ normalized: summary,
637
+ ...(storedValid ? {} : { warning: "读回的 summary 不是合法 Summary[](平台运行时标题解析会抛反序列化异常 → 该模型 data.save/submit 恒 50000);用本工具以 Summary[] 或纯 {字段} 串重写修复" }),
638
+ },
639
+ };
640
+ }, (args) => {
641
+ if (typeof args.summary !== "string" || args.summary.trim().length === 0)
642
+ throw new ToolRefusalError({ status: "refused", blocker: "model-summary-empty", message: "数据标题 summary 不允许为空(平台 validateNotEmpty 同语义)" });
643
+ normalizeSummaryInput(args.summary);
644
+ });
645
+ writeTool(tools, "model.update", MODEL_LANE, MODEL_TOOLS["model.update"].scope, MODEL_TOOLS["model.update"].description, {
646
+ schemaCode: z.string().min(1),
647
+ name: z.string().min(1).optional().describe("新显示名(bizmodels/update 补丁语义,读回 get_all + 应用树节点)"),
648
+ modelType: z.enum(["LIST", "TREE"]).optional().describe("恒拒(model-type-immutable):模型类型创建时固化,bizmodels/update 只更新菜单节点显示"),
649
+ }, async (session, args) => {
650
+ // Source-pinned contract (BizSchemaController.update, 8.6.26
651
+ // decompiled :266-305): POST/PUT bizmodels/update with AppFunctionVO —
652
+ // bizSchemaCode, id (AppFunction node id), name, parentId are ALL
653
+ // validateNotEmpty-required; the endpoint patches the AppFunction MENU
654
+ // NODE (setName/setIcon/setModelType...), it does not migrate the
655
+ // underlying schema (probe evidence: get_all keeps the original type).
656
+ // So: resolve the tree node for id + parentId, patch the name, and read
657
+ // the NODE face back (the schema record name may stay unchanged).
658
+ const resolved = await resolveModelTreeNode(session, args.schemaCode);
659
+ const node = resolved.node;
660
+ if (!node)
661
+ throw new ToolRefusalError({
662
+ status: "refused",
663
+ blocker: "model-update-target-absent",
664
+ schemaCode: args.schemaCode,
665
+ message: `模型 ${args.schemaCode} 无应用树节点(bizmodels/update 只更新 AppFunction 菜单节点,id/parentId 均必需)——确认 schemaCode 或先用 model.create`,
666
+ });
667
+ if (args.name === undefined)
668
+ throw new ToolRefusalError({
669
+ status: "refused",
670
+ blocker: "model-update-empty-patch",
671
+ schemaCode: args.schemaCode,
672
+ message: "没有可应用的补丁字段——至少提供 name(改名节点显示);数据标题用 model.setSummary",
673
+ });
674
+ const nodeId = typeof node.id === "string" ? node.id : String(node.id ?? "");
675
+ const parentId = typeof node.parentId === "string" ? node.parentId : String(node.parentId ?? "");
676
+ if (!nodeId || !parentId)
677
+ throw new WireError("business", `app tree node for ${args.schemaCode} lacks id/parentId (bizmodels/update requires both)`, 10005);
678
+ const performed = await wireCall(session, {
679
+ path: ENDPOINTS.modelUpdate,
680
+ method: "POST",
681
+ body: { bizSchemaCode: args.schemaCode, id: nodeId, parentId, name: args.name },
682
+ });
683
+ // Authoritative readback: the tree NODE name (the patched face). The
684
+ // get_all record face is read informationally — the schema record is
685
+ // NOT migrated by this endpoint (probe evidence).
686
+ const after = await resolveModelTreeNode(session, args.schemaCode);
687
+ const nodeAfter = after.node;
688
+ const record = await wireCall(session, { path: ENDPOINTS.modelGet, query: { schemaCode: args.schemaCode } });
689
+ const recordName = (record.data ?? {}).name ?? null;
690
+ const nodeName = typeof nodeAfter?.name === "string" ? nodeAfter.name : null;
691
+ return {
692
+ performed: { errcode: performed.errcode },
693
+ readback: { schemaCode: args.schemaCode, name: nodeName, renamed: nodeName === args.name, recordName, authoritativeFace: "app-tree-node" },
694
+ };
695
+ }, (args) => {
696
+ // `omc call` bypasses zod, so an extra modelType key still reaches here.
697
+ const raw = args;
698
+ // Probe evidence (yunshu ledger + ticket 09): bizmodels/update only
699
+ // updates the app-menu node display — the underlying schema (type, tree
700
+ // system fields) is FIXED at create. A modelType patch here would look
701
+ // accepted while get_all keeps the original type: refuse it.
702
+ if (raw.modelType !== undefined)
703
+ throw new ToolRefusalError({
704
+ status: "refused",
705
+ blocker: "model-type-immutable",
706
+ schemaCode: raw.schemaCode,
707
+ message: "模型类型在创建时固化——bizmodels/update 只更新应用菜单节点显示,不能把底层 schema 从 LIST 迁移为 TREE(get_all 仍返回原类型与树系统字段,live 取证)。要树模型请建新模型",
708
+ });
709
+ });
710
+ registerPackageTool(tools, {
711
+ name: "configure_model",
712
+ lane: MODEL_LANE,
713
+ scope: MODEL_TOOLS["configure_model"].scope,
714
+ description: MODEL_TOOLS["configure_model"].description,
715
+ inputSchema: {
716
+ name: z.string().min(1),
717
+ code: z.string().min(1),
718
+ folderId: z.string().optional(),
719
+ fieldCode: z.string().optional().describe("legacy single-field form: the one field to create (mutually exclusive with fields[])"),
720
+ fieldName: z.string().optional(),
721
+ propertyType: propertyTypeArg.optional(),
722
+ choices: choicesArg,
723
+ relativeCode: relativeCodeArg,
724
+ options: fieldOptionsArg.optional(),
725
+ subFields: subFieldArg,
726
+ propertyEmpty: z.boolean().optional().describe("legacy single-field form: 数据模型层是否声明不可为空;默认 false。必填/只读请在表单与流程节点配置"),
727
+ fields: z
728
+ .array(z.object({
729
+ code: z.string().min(1),
730
+ name: z.string().min(1),
731
+ propertyType: propertyTypeArg,
732
+ choices: choicesArg,
733
+ relativeCode: relativeCodeArg,
734
+ options: fieldOptionsArg.optional(),
735
+ subFields: subFieldArg,
736
+ propertyEmpty: z.boolean().optional().describe("数据模型层是否声明不可为空;默认 false。必填/只读请在表单与流程节点配置"),
737
+ }))
738
+ .min(1)
739
+ .optional()
740
+ .describe("multi-field form: every business field of the model, created in order (replaces fieldCode/fieldName/propertyType)"),
741
+ publishForm: z.boolean().optional().describe("also draft + publish the model's default form after field.publish, reaching the structurally usable shape in one call"),
742
+ listview: z
743
+ .object({
744
+ fieldCodes: z.array(z.string().min(1)).min(1).describe("列表列(默认视图 code=schemaCode,增量:既有列保留)"),
745
+ code: z.string().optional().describe("视图 code;省略=默认视图"),
746
+ replaceColumns: z.boolean().optional().describe("true=整表列替换(未列出的列会被删除)"),
747
+ hideDefaultSystemFields: z.boolean().optional().describe("true=省略系统列(单号/创建时间等)"),
748
+ })
749
+ .optional()
750
+ .describe("也可在本调用内配置默认列表视图(内部走 listview.configure);一次到「模型+字段+表单+列表」"),
751
+ applicationCode: z.string().optional(),
752
+ },
753
+ // CFG-naming/CFG-02 parity: refuse an invalid code before a plan
754
+ // is minted (the executor's model.create/field.create would otherwise
755
+ // reject a package the human already approved).
756
+ preflight: (args) => {
757
+ const entries = fieldEntriesOf(args);
758
+ const violations = [
759
+ ...validateBusinessCode(args.code, { kind: "model" }),
760
+ ...entries.flatMap((entry) => validateBusinessCode(entry.code, { kind: "field" })),
761
+ ];
762
+ if (violations.length > 0)
763
+ throw new ToolRefusalError({ status: "refused", blocker: "business-code-invalid", message: "code 未过出门前校验;按 validation 修正(长度/风格/保留字)", validation: violations });
764
+ for (const entry of entries)
765
+ prepareFieldChange(fieldChangeInputOf(args.code, entry));
766
+ },
767
+ plan: (args) => {
768
+ const entries = fieldEntriesOf(args);
769
+ return {
770
+ summary: writeSummary("configure_model", args, undefined, "plan"),
771
+ actions: [
772
+ { tool: "model.create", args: { name: args.name, code: args.code, ...(args.folderId ? { folderId: args.folderId } : {}), ...(args.applicationCode ? { applicationCode: args.applicationCode } : {}) } },
773
+ ...entries.map((entry) => ({
774
+ tool: "field.create",
775
+ args: {
776
+ schemaCode: args.code,
777
+ code: entry.code,
778
+ name: entry.name,
779
+ propertyType: entry.propertyType,
780
+ ...(entry.choices ? { choices: entry.choices } : {}),
781
+ ...(entry.relativeCode ? { relativeCode: entry.relativeCode } : {}),
782
+ ...(entry.options ? { options: entry.options } : {}),
783
+ ...(entry.subFields ? { subFields: entry.subFields } : {}),
784
+ ...(entry.propertyEmpty !== undefined ? { propertyEmpty: entry.propertyEmpty } : {}),
785
+ },
786
+ })),
787
+ { tool: "field.publish", args: { schemaCode: args.code, ...(entries.length === 1 ? { code: entries[0].code } : {}) } },
788
+ ...(args.publishForm
789
+ ? [
790
+ { tool: "form.draft", args: { schemaCode: args.code, sheetCode: args.code } },
791
+ { tool: "form.publish", args: { schemaCode: args.code, sheetCode: args.code } },
792
+ ]
793
+ : []),
794
+ ...(args.listview
795
+ ? [{ tool: "listview.configure", args: { schemaCode: args.code, ...args.listview } }]
796
+ : []),
797
+ ],
798
+ };
799
+ },
800
+ execute: async (session, args) => {
801
+ const results = [];
802
+ const entries = fieldEntriesOf(args);
803
+ const stop = (index, tool, error) => {
804
+ const envelope = error instanceof ToolRefusalError
805
+ ? Object.fromEntries(Object.entries(error.payload).filter(([key]) => key !== "status"))
806
+ : error instanceof WireError
807
+ ? { blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message }
808
+ : { blocker: "configure-model-step-failed", message: error instanceof Error ? error.message : String(error) };
809
+ return {
810
+ status: "partial",
811
+ steps: results,
812
+ failedStep: { index, tool },
813
+ envelope: { ...envelope, message: `${String(envelope.message ?? "步骤失败")};后续步骤未执行` },
814
+ };
815
+ };
816
+ const run = async (index, tool, action) => {
817
+ try {
818
+ results.push(await action());
819
+ return undefined;
820
+ }
821
+ catch (error) {
822
+ return stop(index, tool, error);
823
+ }
824
+ };
825
+ const applicationCode = await resolveApplicationCode(session, args.applicationCode);
826
+ const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
827
+ const folder = args.folderId ? tree.find((row) => row.id === args.folderId) : tree.find((row) => row.type !== "BizModel" && typeof row.id === "string");
828
+ if (!folder?.id)
829
+ throw new WireError("business", "no folder found for model creation", 302001);
830
+ // Round 4: resumable. A live run created the model first, then called
831
+ // configure_model, which unconditionally re-ran model.create, hit the
832
+ // platform's duplicate-code error (601001), and aborted before ANY field
833
+ // or form step. The application tree is already in hand, so an existing
834
+ // model is detected with no extra call: skip the create and continue.
835
+ const existingModel = tree.find((row) => row.type === "BizModel" && row.code === args.code);
836
+ let failed;
837
+ if (existingModel) {
838
+ results.push({ step: "model.create", skipped: "model-exists", folderId: String(folder.id), readback: { present: true } });
839
+ }
840
+ else {
841
+ failed = await run(0, "model.create", async () => {
842
+ const performed = await wireCall(session, { path: ENDPOINTS.modelCreate, method: "POST", body: { appCode: applicationCode, name: args.name, parentId: String(folder.id), modelType: MODEL_CREATE_BODY_DEFAULTS.modelType, bizSchemaCode: args.code, icon: MODEL_CREATE_BODY_DEFAULTS.icon, name_i18n: nameI18nJson(args.name), pcAble: MODEL_CREATE_BODY_DEFAULTS.pcAble, mobileAble: MODEL_CREATE_BODY_DEFAULTS.mobileAble, options: MODEL_CREATE_BODY_DEFAULTS.options } });
843
+ const after = await resolveModelTreeNode(session, args.code, applicationCode);
844
+ if (!after.node)
845
+ throw new ToolRefusalError({ status: "refused", blocker: "model-create-readback-failed", message: `model.create 后权威应用树未读到 ${args.code}` });
846
+ return { step: "model.create", errcode: performed.errcode, folderId: String(folder.id), readback: { present: true } };
847
+ });
848
+ if (failed)
849
+ return failed;
850
+ }
851
+ const existingFields = new Map();
852
+ for (const isPublish of ["false", "true"]) {
853
+ for (const row of wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.code, isPublish } })).data))
854
+ existingFields.set(String(row.code ?? row.propertyCode ?? ""), row);
855
+ }
856
+ for (const entry of entries) {
857
+ const input = fieldChangeInputOf(args.code, entry);
858
+ failed = await run(results.length, "field.create", async () => {
859
+ const existing = existingFields.get(entry.code);
860
+ if (existing !== undefined) {
861
+ if (sameFieldChange(existing, input))
862
+ return { step: "field.create", skipped: "field-exists", code: entry.code, readback: { present: true } };
863
+ throw new ToolRefusalError({
864
+ status: "refused",
865
+ blocker: "field-conflict",
866
+ schemaCode: args.code,
867
+ propertyCode: entry.code,
868
+ message: `字段 ${args.code}.${entry.code} 已存在且形状与请求不同;configure_model 只创建缺失字段,不改写既有字段。先核对既有形状(omc call field.list --args '{"schemaCode":"${args.code}","published":false,"code":"${entry.code}"}'),要改写走 field.update,要新字段就换 code`,
869
+ });
870
+ }
871
+ const prepared = prepareFieldChange(input);
872
+ const performed = await wireCall(session, { path: ENDPOINTS.fieldCreate, method: "POST", body: fieldChangeWireBody(input, prepared) });
873
+ const after = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.code, isPublish: "false" } })).data);
874
+ if (!after.some((row) => String(row.code ?? row.propertyCode) === entry.code))
875
+ throw new ToolRefusalError({ status: "refused", blocker: "field-create-readback-failed", message: `field.create 后权威草稿字段未读到 ${entry.code}` });
876
+ return { step: "field.create", errcode: performed.errcode, code: entry.code, readback: { present: true } };
877
+ });
878
+ if (failed)
879
+ return failed;
880
+ }
881
+ failed = await run(results.length, "field.publish", async () => {
882
+ const performed = await wireCall(session, { path: ENDPOINTS.fieldPublish, method: "POST", body: { bizSchemaCode: args.code, sendMessage: false } });
883
+ const after = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.code, isPublish: "true" } })).data);
884
+ const publishedCodes = new Set(after.map((row) => String(row.code ?? row.propertyCode)));
885
+ if (entries.some((entry) => !publishedCodes.has(entry.code)))
886
+ throw new ToolRefusalError({ status: "refused", blocker: "field-publish-readback-failed", message: "field.publish 后权威发布面缺少请求字段" });
887
+ return { step: "field.publish", errcode: performed.errcode, scope: "model", readback: { publishedCodes: [...publishedCodes] } };
888
+ });
889
+ if (failed)
890
+ return failed;
891
+ if (args.publishForm) {
892
+ // Reuse the proven configure_form paths so the scaffold/publish
893
+ // semantics are identical to the form lane.
894
+ failed = await run(results.length, "form.draft", async () => {
895
+ const outcome = await executeFormDraftWrite(session, { schemaCode: args.code, sheetCode: args.code, fieldCodes: entries.map((entry) => entry.code) });
896
+ if (outcome.readback.hasDraft !== true)
897
+ throw new ToolRefusalError({ status: "refused", blocker: "form-draft-readback-failed", message: "form.draft 后权威表单未读到草稿" });
898
+ return { step: "form.draft", errcode: outcome.performed.errcode, readback: outcome.readback };
899
+ });
900
+ if (failed)
901
+ return failed;
902
+ failed = await run(results.length, "form.publish", async () => {
903
+ const outcome = await executeFormPublishWrite(session, { schemaCode: args.code, sheetCode: args.code });
904
+ if (outcome.readback.published !== true)
905
+ throw new ToolRefusalError({ status: "refused", blocker: "form-publish-readback-failed", message: "form.publish 后权威表单未读到 published=true" });
906
+ return { step: "form.publish", errcode: outcome.performed.errcode, readback: outcome.readback };
907
+ });
908
+ if (failed)
909
+ return failed;
910
+ }
911
+ if (args.listview) {
912
+ // Delegate to the list-view lane's own executor so the gate/readback are
913
+ // identical to a direct listview.configure.
914
+ const target = tools.find((tool) => tool.name === "listview.configure");
915
+ if (!target?.writeExecute)
916
+ throw new ToolRefusalError({ status: "refused", blocker: "configure-model-listview-unavailable", message: "listview.configure 未注册写执行" });
917
+ failed = await run(results.length, "listview.configure", async () => {
918
+ const outcome = await target.writeExecute(session, { schemaCode: args.code, ...args.listview });
919
+ return { step: "listview.configure", readback: outcome.readback };
920
+ });
921
+ if (failed)
922
+ return failed;
923
+ }
924
+ const published = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.code, isPublish: "true" } });
925
+ const rows = wireRows(published.data);
926
+ const publishedCodes = rows.map((row) => String(row.code ?? row.propertyCode ?? ""));
927
+ const readback = {
928
+ publishedCodes,
929
+ ...(entries.length === 1
930
+ ? { publishedFieldPresent: publishedCodes.includes(entries[0].code) }
931
+ : { publishedFieldsPresent: Object.fromEntries(entries.map((entry) => [entry.code, publishedCodes.includes(entry.code)])) }),
932
+ };
933
+ if (args.publishForm) {
934
+ const sheet = ((await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.code, sheetCode: args.code } })).data ?? {});
935
+ readback.formPublished = sheet.published === true;
936
+ }
937
+ return { steps: results, readback };
938
+ },
939
+ summarize: (args, _outcome, phase) => writeSummary("configure_model", args, undefined, phase),
940
+ });
941
+ }
942
+ /**
943
+ * Ticket 47 — the field entries of a configure_model call: the multi-field
944
+ * `fields[]` array when present, else the legacy single-field triple. The
945
+ * family gate (assertFieldFamilyAllowed) runs per entry BEFORE the write permission gate
946
+ * gate, exactly like a bare field.create call.
947
+ */
948
+ function fieldEntriesOf(args) {
949
+ const withLegacy = (entry) => ({
950
+ ...(entry.choices ? { choices: entry.choices } : {}),
951
+ ...(entry.relativeCode ? { relativeCode: entry.relativeCode } : {}),
952
+ ...(entry.options ? { options: entry.options } : {}),
953
+ ...(entry.subFields ? { subFields: entry.subFields } : {}),
954
+ ...(entry.propertyEmpty !== undefined ? { propertyEmpty: entry.propertyEmpty } : {}),
955
+ });
956
+ if (Array.isArray(args.fields) && args.fields.length > 0) {
957
+ if (args.fieldCode !== undefined || args.fieldName !== undefined || args.propertyType !== undefined)
958
+ throw new ToolRefusalError({ status: "refused", blocker: "configure-model-fields-exclusive", message: "fields[] 与单字段参数(fieldCode/fieldName/propertyType)互斥;多字段用 fields[],单字段用旧三元组" });
959
+ for (const entry of args.fields)
960
+ assertFieldFamilyAllowed({ ...entry });
961
+ return args.fields.map((entry) => ({ code: entry.code, name: entry.name, propertyType: entry.propertyType, ...withLegacy(entry) }));
962
+ }
963
+ if (typeof args.fieldCode !== "string" || typeof args.fieldName !== "string" || typeof args.propertyType !== "string")
964
+ throw new ToolRefusalError({
965
+ status: "refused",
966
+ blocker: "configure-model-fields-missing",
967
+ message: "configure_model 需要 fields[](多字段)或 fieldCode+fieldName+propertyType(单字段)",
968
+ });
969
+ const legacy = { code: args.fieldCode, name: args.fieldName, propertyType: args.propertyType, ...withLegacy(args) };
970
+ assertFieldFamilyAllowed(legacy);
971
+ return [legacy];
972
+ }
973
+ function fieldChangeInputOf(schemaCode, entry) {
974
+ return {
975
+ schemaCode,
976
+ code: entry.code,
977
+ name: entry.name,
978
+ propertyType: entry.propertyType,
979
+ ...(entry.options ? { options: entry.options } : {}),
980
+ ...(entry.choices ? { choices: entry.choices } : {}),
981
+ ...(entry.relativeCode ? { relativeCode: entry.relativeCode } : {}),
982
+ ...(entry.subFields ? { subFields: entry.subFields } : {}),
983
+ ...(entry.propertyEmpty !== undefined ? { propertyEmpty: entry.propertyEmpty } : {}),
984
+ };
985
+ }
986
+ //# sourceMappingURL=model.js.map