@stalfh233/omc-cli 0.4.90 → 0.4.92

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.
@@ -1366,6 +1366,43 @@
1366
1366
  "app-tree folder rename/move (folders/update)",
1367
1367
  "app-tree folder delete (folders/delete, non-recursive)"
1368
1368
  ]
1369
+ },
1370
+ {
1371
+ "capability": "Custom pages (自定义页面): app-tree node + JS/HTML body (draft/publish)",
1372
+ "status": "executable",
1373
+ "scope": "Custom pages: app-tree node (AppFunction type=Page) create/update/delete/list plus the page body (CustomPageModel draftContent/publishedContent) read, draft write, and publish. Node-first is enforced because /page/content/* resolves the AppFunction by code.",
1374
+ "version_family": "8.6",
1375
+ "anchor_version": "8.6.26",
1376
+ "evidence": [
1377
+ {
1378
+ "path": "cp-sources/backend/decompiled-full/launcher-8.6.26/cloudpivot-engine-8.6.26/com/authine/cloudpivot/web/api/controller/app/CustomPageController.java"
1379
+ },
1380
+ {
1381
+ "path": "packages/omc-cli/src/lanes/custom-page.ts"
1382
+ }
1383
+ ],
1384
+ "lane": "app-tree-folders",
1385
+ "notes": "CustomPageController (@RequestMapping /api/custom): /create requires parentId and a non-null menuPointType (NPE→50000 otherwise) and forces type=Page; /page/content/update writes draftContent, /page/content/publish writes publishedContent, both require the AppFunction to exist (BIZ_SCHEMA_NOT_EXIST otherwise).",
1386
+ "tools": [
1387
+ "custompage.list",
1388
+ "custompage.get",
1389
+ "custompage.create",
1390
+ "custompage.update",
1391
+ "custompage.content.get",
1392
+ "custompage.content.update",
1393
+ "custompage.content.publish",
1394
+ "custompage.delete"
1395
+ ],
1396
+ "scopes": [
1397
+ "app-tree custom page list (custom/page/list_by_appcode)",
1398
+ "app-tree custom page node (custom/get)",
1399
+ "app-tree custom page create (custom/create, AppFunction type=Page + CustomPageModel)",
1400
+ "app-tree custom page update (custom/update)",
1401
+ "custom page body read (custom/page/content/get)",
1402
+ "custom page body draft write (custom/page/content/update)",
1403
+ "custom page body publish (custom/page/content/publish)",
1404
+ "custom page delete (custom/delete)"
1405
+ ]
1369
1406
  }
1370
1407
  ]
1371
1408
  }
package/dist/endpoints.js CHANGED
@@ -33,6 +33,24 @@ export const ENDPOINTS = {
33
33
  folderGet: "/api/api/app/folders/get",
34
34
  folderUpdate: "/api/api/app/folders/update",
35
35
  folderDelete: "/api/api/app/folders/delete",
36
+ /**
37
+ * Custom pages (自定义页面) — decompiled 8.6.26 CustomPageController
38
+ * (`@RequestMapping("/api/custom")`). The app-tree node and the page body are
39
+ * TWO rows: `/create` makes the AppFunction node (type=Page) plus an empty
40
+ * CustomPageModel; `/page/content/update` writes draftContent/publishedContent
41
+ * onto that model and auto-creates it if absent (requires the AppFunction).
42
+ */
43
+ customPageCreate: "/api/api/custom/create",
44
+ customPageUpdate: "/api/api/custom/update",
45
+ customPageGet: "/api/api/custom/get",
46
+ /** `/custom/list?modeType=` returns every CustomPageModel row (the node face
47
+ * `/custom/page/list_by_appcode` filters NodeType.CustomPage and misses rows
48
+ * created by /custom/create, which forces NodeType.Page — live 1.1.25). */
49
+ customPageList: "/api/api/custom/list",
50
+ customPageDelete: "/api/api/custom/delete",
51
+ customPageContentGet: "/api/api/custom/page/content/get",
52
+ customPageContentUpdate: "/api/api/custom/page/content/update",
53
+ customPageContentPublish: "/api/api/custom/page/content/publish",
36
54
  appPackagePhysicallyDelete: "/api/api/app/apppackage/physically_delete",
37
55
  fieldList: "/api/api/app/bizproperty/list",
38
56
  fieldGet: "/api/api/app/bizproperty/get",
@@ -610,6 +610,13 @@ export function goalRoute(registry, goal) {
610
610
  firstCommands: [
611
611
  DISCOVERY_AUTHORITY.readiness,
612
612
  ...ladder.flatMap((step) => step.recipe?.command ? [step.recipe.command] : []),
613
+ // r13 #3: `capability workflow --executable` is 23 KB and still did not
614
+ // answer "how do I submit an approval" — the answer is a data.submit
615
+ // recipe, which lives in a DIFFERENT capability. When the goal is about
616
+ // moving an approval, hand over that example directly.
617
+ ...(/审批|通过|驳回|提交|签收/.test(goal) && ordered.some((id) => id === "workflow" || id === "runtimeData")
618
+ ? ["omc describe data.submit --example workflow-backed-data-submit"]
619
+ : []),
613
620
  ],
614
621
  write,
615
622
  ...(readish ? { readback: true } : {}),
@@ -635,6 +642,11 @@ export function goalRoute(registry, goal) {
635
642
  firstCommands.push(`omc commands --json --compact --lane ${route.lane}`);
636
643
  else
637
644
  firstCommands.push(`omc capability ${capabilityId}`);
645
+ // r13 #3: an approval-moving goal lands on the `workflow` capability whose
646
+ // 23 KB executable bundle never answered "how do I submit" — that answer is a
647
+ // data.submit recipe in another lane. Hand it over directly.
648
+ if (!readish && /审批|通过|驳回|提交|签收/.test(goal) && (capabilityId === "workflow" || capabilityId === "runtimeData"))
649
+ firstCommands.push("omc describe data.submit --example workflow-backed-data-submit");
638
650
  const whole = isWholePackageWrite(mode, goal, 1);
639
651
  return {
640
652
  status: "ok",
@@ -0,0 +1,283 @@
1
+ import { z } from "zod";
2
+ import { ToolRefusalError, defineTool } from "../tool-types.js";
3
+ import { governedRead } from "../read.js";
4
+ import { writeTool } from "../write.js";
5
+ import { wireCall, wireRows, WireError } from "../wire.js";
6
+ import { ENDPOINTS } from "../endpoints.js";
7
+ import { capabilityArg, environmentArg } from "../args.js";
8
+ import { resolveApplicationCode } from "../support.js";
9
+ import { LANES } from "./lane-ids.js";
10
+ import { FOLDER_LANE_CONTRACT } from "./folder-contract.js";
11
+ /**
12
+ * Custom pages (自定义页面) — the JS/HTML page a customer embeds in the portal.
13
+ *
14
+ * Evidence: decompiled 8.6.26
15
+ * `cloudpivot-engine/com/authine/cloudpivot/web/api/controller/app/CustomPageController.java`
16
+ * (`@RequestMapping("/api/custom")`). Two rows move together:
17
+ * - the app-tree node: `AppFunctionModel` type=Page, created by POST /create
18
+ * with {name, code, parentId, appCode, menuPointType, pcAble, mobileAble,
19
+ * openMode}. `menuPointType` is REQUIRED (the controller calls
20
+ * `getMenuPointType().toString()` → NPE/50000 when null); for this controller
21
+ * it is `CUSTOM_PAGE` (MenuPointType enum: URL/BIZ_SCHEME/REPORT/
22
+ * CUSTOM_PAGE/WORKFLOW_CENTER_PAGE/AI_AGENT).
23
+ * - the body: `CustomPageModel.draftContent` / `.publishedContent`, written by
24
+ * /page/content/update (draft, sets existDraft=true) and /page/content/publish
25
+ * (published, sets published=true). Both require the AppFunction to exist
26
+ * (`getAppFunctionByCode(code)` → else BIZ_SCHEMA_NOT_EXIST) and both will
27
+ * CREATE the CustomPageModel when absent, so "node first, then body" is the
28
+ * only working order.
29
+ */
30
+ const LANE = LANES.folder;
31
+ const T = FOLDER_LANE_CONTRACT.tools;
32
+ const MENU_POINT_TYPES = ["URL", "BIZ_SCHEME", "REPORT", "CUSTOM_PAGE", "WORKFLOW_CENTER_PAGE", "AI_AGENT"];
33
+ /**
34
+ * Node read that tolerates the platform's "absent" answer: `/custom/get` throws
35
+ * 302033 BIZ_CUSTOM_SHEET_EMPTY (not null) when the row is missing, so a plain
36
+ * null check never fires and a delete stops being idempotent.
37
+ */
38
+ async function readPageOrNull(session, code) {
39
+ try {
40
+ return (await wireCall(session, { path: ENDPOINTS.customPageGet, query: { code } })).data ?? null;
41
+ }
42
+ catch (error) {
43
+ if (error instanceof WireError && error.kind === "business" && (error.errcode === 302033 || error.errcode === 50000))
44
+ return null;
45
+ throw error;
46
+ }
47
+ }
48
+ function pickPage(row) {
49
+ return {
50
+ code: row.code ?? null,
51
+ name: row.name ?? null,
52
+ appCode: row.appCode ?? null,
53
+ parentId: row.parentId ?? null,
54
+ icon: row.icon ?? null,
55
+ openMode: row.openMode ?? null,
56
+ pcUrl: row.pcUrl ?? null,
57
+ mobileUrl: row.mobileUrl ?? null,
58
+ pcAble: row.pcAble ?? null,
59
+ mobileAble: row.mobileAble ?? null,
60
+ menuPointType: row.menuPointType ?? null,
61
+ published: row.published ?? null,
62
+ existDraft: row.existDraft ?? null,
63
+ ...(typeof row.draftContent === "string" ? { draftBytes: Buffer.byteLength(row.draftContent, "utf8") } : {}),
64
+ ...(typeof row.publishedContent === "string" ? { publishedBytes: Buffer.byteLength(row.publishedContent, "utf8") } : {}),
65
+ };
66
+ }
67
+ export function registerCustomPageTools(tools) {
68
+ tools.push(defineTool({
69
+ name: "custompage.list",
70
+ layer: "low",
71
+ kind: "read",
72
+ lane: LANE,
73
+ scope: T["custompage.list"].scope,
74
+ description: T["custompage.list"].description,
75
+ inputSchema: {
76
+ environment: environmentArg,
77
+ applicationCode: z.string().optional().describe("应用 code;省略用配置/默认应用"),
78
+ type: z.enum(["PC", "MOBILE"]).optional().describe("门户端过滤(NodeType);省略两端口都列"),
79
+ capability: capabilityArg,
80
+ },
81
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
82
+ const appCode = await resolveApplicationCode(session, args.applicationCode);
83
+ // `/custom/list?modeType=` answers the CustomPageModel rows. The node face
84
+ // (`/custom/page/list_by_appcode`) filters NodeType.CustomPage while
85
+ // /custom/create forces NodeType.Page, so it answers [] for pages this tool
86
+ // creates (live 1.1.25) — do not use it for "which pages exist".
87
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.customPageList, query: { modeType: "VUE_MODE" } })).data);
88
+ const pages = rows.map(pickPage).filter((page) => appCode === "" || String(page.appCode ?? "") === appCode);
89
+ return { applicationCode: appCode, count: pages.length, pages };
90
+ }),
91
+ }));
92
+ tools.push(defineTool({
93
+ name: "custompage.get",
94
+ layer: "low",
95
+ kind: "read",
96
+ lane: LANE,
97
+ scope: T["custompage.get"].scope,
98
+ description: T["custompage.get"].description,
99
+ inputSchema: {
100
+ environment: environmentArg,
101
+ code: z.string().min(1),
102
+ content: z.boolean().optional().describe("true = 同时读 draftContent/publishedContent 原文(大;默认只回字节数)"),
103
+ capability: capabilityArg,
104
+ },
105
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
106
+ const page = await readPageOrNull(session, args.code);
107
+ if (page === null)
108
+ throw new ToolRefusalError({ status: "refused", blocker: "custompage-absent", code: args.code, message: `自定义页 ${args.code} 不存在(/custom/get 回 302033);先 custompage.list 或 custompage.create` });
109
+ const projected = pickPage(page);
110
+ // /custom/get answers the CustomPageModel row, which does not carry the
111
+ // tree parent — say so instead of letting parentId:null look like "root".
112
+ if (projected.parentId === null || projected.parentId === undefined)
113
+ projected.parentIdNote = "parentId 不在 /custom/get 的返回里(它属于应用树 AppFunction 行);要改父节点请 custompage.update 显式传 parentId";
114
+ if (args.content !== true)
115
+ return { code: args.code, present: true, page: projected, contentHint: "要页面正文(draftContent/publishedContent 原文)加 content:true,或用 custompage.content.get" };
116
+ const body = (await wireCall(session, { path: ENDPOINTS.customPageContentGet, query: { code: args.code } })).data;
117
+ return {
118
+ code: args.code,
119
+ present: true,
120
+ page: projected,
121
+ content: {
122
+ draftContent: body?.draftContent ?? null,
123
+ publishedContent: body?.publishedContent ?? null,
124
+ existDraft: body?.existDraft ?? null,
125
+ published: body?.published ?? null,
126
+ },
127
+ };
128
+ }),
129
+ }));
130
+ writeTool(tools, "custompage.create", LANE, T["custompage.create"].scope, T["custompage.create"].description, {
131
+ name: z.string().min(1),
132
+ code: z.string().min(1).describe("页面 code(≤28 字符,平台 validateCode28)"),
133
+ parentId: z.string().min(1).describe("父节点 id(应用根或目录);用 folder.tree 取"),
134
+ applicationCode: z.string().optional().describe("应用 code;省略用配置/默认应用"),
135
+ menuPointType: z.enum(MENU_POINT_TYPES).optional().describe("菜单类型;自定义页固定 CUSTOM_PAGE(省略即用 CUSTOM_PAGE)"),
136
+ icon: z.string().optional(),
137
+ pcAble: z.boolean().optional().describe("PC 端可见(默认 true)"),
138
+ mobileAble: z.boolean().optional().describe("移动端可见(默认 true)"),
139
+ openMode: z.string().optional().describe("打开方式;默认 VUE_MODE(平台建 CustomPageModel 时也用这个)"),
140
+ }, async (session, args) => {
141
+ const appCode = await resolveApplicationCode(session, args.applicationCode);
142
+ const body = {
143
+ name: args.name,
144
+ name_i18n: JSON.stringify({ zh: args.name }),
145
+ code: args.code,
146
+ parentId: args.parentId,
147
+ appCode,
148
+ menuPointType: args.menuPointType ?? "CUSTOM_PAGE",
149
+ pcAble: args.pcAble !== false,
150
+ mobileAble: args.mobileAble !== false,
151
+ openMode: args.openMode ?? "VUE_MODE",
152
+ ...(args.icon !== undefined ? { icon: args.icon } : {}),
153
+ };
154
+ const performed = await wireCall(session, { path: ENDPOINTS.customPageCreate, method: "POST", body });
155
+ const readback = await readPageOrNull(session, args.code);
156
+ return {
157
+ performed: { errcode: performed.errcode },
158
+ readback: { code: args.code, present: readback !== null, page: readback ? pickPage(readback) : null, next: `写入页面正文:omc call custompage.content.update --args '{"code":"${args.code}","content":"<JS/HTML>"}'` },
159
+ };
160
+ });
161
+ writeTool(tools, "custompage.update", LANE, T["custompage.update"].scope, T["custompage.update"].description, {
162
+ name: z.string().optional(),
163
+ code: z.string().min(1),
164
+ parentId: z.string().min(1).describe("父节点 id(平台 update 强制必填,省略会 50000)"),
165
+ menuPointType: z.enum(MENU_POINT_TYPES).optional(),
166
+ icon: z.string().optional(),
167
+ pcAble: z.boolean().optional(),
168
+ mobileAble: z.boolean().optional(),
169
+ openMode: z.string().optional(),
170
+ }, async (session, args) => {
171
+ const current = await readPageOrNull(session, args.code);
172
+ if (current === null)
173
+ throw new ToolRefusalError({ status: "refused", blocker: "custompage-absent", code: args.code, message: `自定义页 ${args.code} 不存在;先 custompage.create` });
174
+ const body = {
175
+ ...current,
176
+ name: args.name ?? current.name,
177
+ parentId: args.parentId,
178
+ menuPointType: args.menuPointType ?? current.menuPointType ?? "CUSTOM_PAGE",
179
+ pcAble: args.pcAble ?? current.pcAble ?? true,
180
+ mobileAble: args.mobileAble ?? current.mobileAble ?? true,
181
+ ...(args.openMode !== undefined ? { openMode: args.openMode } : {}),
182
+ ...(args.icon !== undefined ? { icon: args.icon } : {}),
183
+ };
184
+ const performed = await wireCall(session, { path: ENDPOINTS.customPageUpdate, method: "POST", body });
185
+ const readback = await readPageOrNull(session, args.code);
186
+ return { performed: { errcode: performed.errcode }, readback: { code: args.code, page: readback ? pickPage(readback) : null } };
187
+ });
188
+ tools.push(defineTool({
189
+ name: "custompage.content.get",
190
+ layer: "low",
191
+ kind: "read",
192
+ lane: LANE,
193
+ scope: T["custompage.content.get"].scope,
194
+ description: T["custompage.content.get"].description,
195
+ inputSchema: {
196
+ environment: environmentArg,
197
+ code: z.string().min(1),
198
+ part: z.enum(["draft", "published", "both"]).optional().describe("默认 both;只要一侧就指名,省输出"),
199
+ capability: capabilityArg,
200
+ },
201
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
202
+ const body = (await wireCall(session, { path: ENDPOINTS.customPageContentGet, query: { code: args.code } })).data;
203
+ if (body === null)
204
+ throw new ToolRefusalError({ status: "refused", blocker: "custompage-absent", code: args.code, message: `自定义页 ${args.code} 没有正文记录(/page/content/get 空);先 custompage.create,再 custompage.content.update` });
205
+ const part = args.part ?? "both";
206
+ const draft = typeof body.draftContent === "string" ? body.draftContent : null;
207
+ const published = typeof body.publishedContent === "string" ? body.publishedContent : null;
208
+ return {
209
+ code: args.code,
210
+ existDraft: body.existDraft ?? null,
211
+ published: body.published ?? null,
212
+ ...(part !== "published" ? { draftContent: draft, draftBytes: draft === null ? 0 : Buffer.byteLength(draft, "utf8") } : {}),
213
+ ...(part !== "draft" ? { publishedContent: published, publishedBytes: published === null ? 0 : Buffer.byteLength(published, "utf8") } : {}),
214
+ };
215
+ }),
216
+ }));
217
+ writeTool(tools, "custompage.content.update", LANE, T["custompage.content.update"].scope, T["custompage.content.update"].description, {
218
+ code: z.string().min(1),
219
+ content: z.string().describe("页面正文(JS/HTML)。大体积请用 --args-file 传整个 args 对象,避免命令行引号/长度限制"),
220
+ publish: z.boolean().optional().describe("true = 同一调用里再走 /page/content/publish(草稿 + 发布都写),默认 false 只存草稿"),
221
+ }, async (session, args) => {
222
+ const performed = await wireCall(session, {
223
+ path: ENDPOINTS.customPageContentUpdate,
224
+ method: "POST",
225
+ body: { code: args.code, draftContent: args.content, publishedContent: args.content },
226
+ });
227
+ let publishErrcode = null;
228
+ if (args.publish === true) {
229
+ const published = await wireCall(session, {
230
+ path: ENDPOINTS.customPageContentPublish,
231
+ method: "POST",
232
+ body: { code: args.code, draftContent: args.content, publishedContent: args.content },
233
+ });
234
+ publishErrcode = published.errcode;
235
+ }
236
+ const readback = (await wireCall(session, { path: ENDPOINTS.customPageContentGet, query: { code: args.code } })).data;
237
+ const draft = typeof readback?.draftContent === "string" ? readback.draftContent : null;
238
+ const publishedContent = typeof readback?.publishedContent === "string" ? readback.publishedContent : null;
239
+ return {
240
+ performed: { errcode: performed.errcode, ...(publishErrcode === null ? {} : { publishErrcode }) },
241
+ readback: {
242
+ code: args.code,
243
+ existDraft: readback?.existDraft ?? null,
244
+ published: readback?.published ?? null,
245
+ draftMatches: draft === args.content,
246
+ publishedMatches: publishedContent === args.content,
247
+ ...(publishErrcode === null ? { publishHint: "只写了草稿:门户读 publishedContent。要上线再 custompage.content.publish(或本工具 publish:true)" } : {}),
248
+ },
249
+ };
250
+ });
251
+ writeTool(tools, "custompage.content.publish", LANE, T["custompage.content.publish"].scope, T["custompage.content.publish"].description, {
252
+ code: z.string().min(1),
253
+ content: z.string().optional().describe("要发布的正文;省略则把当前 draftContent 发布出去"),
254
+ }, async (session, args) => {
255
+ const current = (await wireCall(session, { path: ENDPOINTS.customPageContentGet, query: { code: args.code } })).data;
256
+ const draft = typeof current?.draftContent === "string" ? current.draftContent : null;
257
+ const content = args.content ?? draft;
258
+ if (content === null)
259
+ throw new ToolRefusalError({ status: "refused", blocker: "custompage-content-absent", code: args.code, message: `自定义页 ${args.code} 没有草稿正文,且本次未传 content;先 custompage.content.update` });
260
+ const performed = await wireCall(session, {
261
+ path: ENDPOINTS.customPageContentPublish,
262
+ method: "POST",
263
+ body: { code: args.code, draftContent: draft ?? content, publishedContent: content },
264
+ });
265
+ const readback = (await wireCall(session, { path: ENDPOINTS.customPageContentGet, query: { code: args.code } })).data;
266
+ const publishedContent = typeof readback?.publishedContent === "string" ? readback.publishedContent : null;
267
+ return {
268
+ performed: { errcode: performed.errcode },
269
+ readback: { code: args.code, published: readback?.published ?? null, publishedMatches: publishedContent === content, publishedBytes: publishedContent === null ? 0 : Buffer.byteLength(publishedContent, "utf8") },
270
+ };
271
+ });
272
+ writeTool(tools, "custompage.delete", LANE, T["custompage.delete"].scope, T["custompage.delete"].description, {
273
+ code: z.string().min(1),
274
+ }, async (session, args) => {
275
+ const current = await readPageOrNull(session, args.code);
276
+ if (current === null)
277
+ return { performed: { skipped: "already-absent" }, readback: { code: args.code, absent: true } };
278
+ const performed = await wireCall(session, { path: ENDPOINTS.customPageDelete, query: { code: args.code } });
279
+ const readback = await readPageOrNull(session, args.code);
280
+ return { performed: { errcode: performed.errcode }, readback: { code: args.code, absent: readback === null } };
281
+ });
282
+ }
283
+ //# sourceMappingURL=custom-page.js.map
@@ -301,7 +301,34 @@ export function registerFieldLane(tools) {
301
301
  // One seam owns field-change normalization, every reproduced gate, and the
302
302
  // wire body (src/field-change.ts), so configure_model and intent share it.
303
303
  const prepared = prepareFieldChange(args);
304
- const performed = await wireCall(session, { path: ENDPOINTS.fieldCreate, method: "POST", body: fieldChangeWireBody(args, prepared) });
304
+ let performed;
305
+ try {
306
+ performed = await wireCall(session, { path: ENDPOINTS.fieldCreate, method: "POST", body: fieldChangeWireBody(args, prepared) });
307
+ }
308
+ catch (error) {
309
+ // r12 A5: the platform answers a bare 300005「数据模型已经存在!」when the
310
+ // code collides. BizObjectSchemaManagerImpl#addBizSchema looks the code up
311
+ // in the GLOBAL schema registry (`getBizObjectSchema(code)`), and child
312
+ // tables are registered there too (`schema.isSubSchema()` →
313
+ // `_addSubBizSchema`) — so a subtable code shares the model namespace and
314
+ // collides across models, not just inside this one. The registry lookup is
315
+ // not exposed over REST, so the occupier cannot be listed here; say that
316
+ // instead of leaving a bare platform code.
317
+ if (error instanceof WireError && error.kind === "business" && error.errcode === 300005)
318
+ throw new ToolRefusalError({
319
+ status: "refused",
320
+ blocker: "schema-code-taken",
321
+ schemaCode: args.schemaCode,
322
+ code: args.code,
323
+ nextCommand: `omc call field.list --args '${JSON.stringify({ schemaCode: args.schemaCode, published: false })}'`,
324
+ message: `code ${args.code} 已被占用(300005 数据模型已经存在)。**模型与子表共用同一个全局 code 命名空间**——` +
325
+ `反编译取证的判定点是 BizObjectSchemaManagerImpl#addBizSchema 的 getBizObjectSchema(code),子表走同一注册表(isSubSchema → _addSubBizSchema),` +
326
+ `所以子表 code 与别的模型的子表/模型 code 撞了也会报这条,而不只是本模型内重复。` +
327
+ `本模型内的重复本工具已提前拦住;剩下的是跨模型占用。平台没有「按 code 查占用者」的 REST 读面(只有 _addSubBizSchema 内部注册表),所以这里列不出占用者。` +
328
+ `处理:换一个更具体的 code(建议 <模型前缀>_<语义>);如果确信是上一轮残留,用 model-purge 清掉那个模型后再试。`,
329
+ });
330
+ throw error;
331
+ }
305
332
  const readback = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } });
306
333
  const rows = wireRows(readback.data);
307
334
  return {
@@ -15,6 +15,7 @@ export const FOLDER_WIRE = Object.freeze({
15
15
  defaults: "createFolder forces type=Folder, defaults parentId to the app package id (root) and does NOT reject duplicate names; updateFolder refuses parentId===id and only writes parentId when non-empty; deleteFolder refuses a non-empty folder (DIRECTORY_NOT_EMPTY) — delete is NOT recursive",
16
16
  tree: "apppackage/trees?appCode= returns a NESTED tree (folders carry children[]); reading only the top-level array makes a freshly created child folder look absent — flatten recursively and report path/depth",
17
17
  live: "2026-10-23 customer HAR replay: folders/create answered errcode 0 成功创建目录; after the token was attached apppackage/trees returned the full tree",
18
+ customPage: "CustomPageController(8.6.26, @RequestMapping \"/api/custom\"): POST /create takes AppFunctionVO and requires parentId + a non-null menuPointType (getMenuPointType().toString() NPEs to 50000 otherwise) and forces type=Page; POST /page/content/update writes draftContent (existDraft=true) and /page/content/publish writes publishedContent (published=true); BOTH first do getAppFunctionByCode(code) and throw BIZ_SCHEMA_NOT_EXIST when the tree node is missing — node first, then body",
18
19
  });
19
20
  export const FOLDER_LANE_CONTRACT = Object.freeze({
20
21
  lane: "app-tree-folders",
@@ -26,12 +27,19 @@ export const FOLDER_LANE_CONTRACT = Object.freeze({
26
27
  get: "folders/get",
27
28
  update: "folders/update",
28
29
  delete: "folders/delete",
30
+ customPageCreate: "custom/create",
31
+ customPageList: "custom/page/list_by_appcode",
32
+ customPageContentGet: "custom/page/content/get",
33
+ customPageContentUpdate: "custom/page/content/update",
34
+ customPageContentPublish: "custom/page/content/publish",
35
+ customPageDelete: "custom/delete",
29
36
  }),
30
37
  wire: FOLDER_WIRE,
31
38
  wireBindings: Object.freeze({
32
39
  endpoints: Object.freeze([behaviorBinding("test/app-folder-and-model-move.test.ts#model.move reparents the model; folder.delete refuses a non-empty folder", assertFolderContract)]),
33
40
  defaults: Object.freeze([behaviorBinding("test/app-folder-and-model-move.test.ts#model.move reparents the model; folder.delete refuses a non-empty folder", assertFolderContract)]),
34
41
  tree: Object.freeze([behaviorBinding("test/app-folder-and-model-move.test.ts#folder.create then folder.tree sees the folder (nested tree is flattened)", assertFolderContract)]),
42
+ customPage: Object.freeze([behaviorBinding("test/custom-page.test.ts#custompage.create then content.update/publish round-trips the body; body without a node is refused", assertFolderContract)]),
35
43
  live: Object.freeze([behaviorBinding("test/app-folder-and-model-move.test.ts#folder.create then folder.tree sees the folder (nested tree is flattened)", assertFolderContract)]),
36
44
  }),
37
45
  evidence: Object.freeze([
@@ -44,6 +52,18 @@ export const FOLDER_LANE_CONTRACT = Object.freeze({
44
52
  "folder.create": Object.freeze({ scope: "app-tree folder create (folders/create)", description: "新建目录:folders/create 省略 parentId 建顶层目录;同父同名默认拒绝(folder-duplicate)" }),
45
53
  "folder.update": Object.freeze({ scope: "app-tree folder rename/move (folders/update)", description: "重命名/挂载目录(不移动目录内模型,模型用 model.move);parentId 不可等于自身" }),
46
54
  "folder.delete": Object.freeze({ scope: "app-tree folder delete (folders/delete, non-recursive)", description: "删除空目录:非递归,有子节点回 DIRECTORY_NOT_EMPTY(工具先查子节点,拒 folder-not-empty)" }),
55
+ // Custom pages ride the SAME app tree (AppFunction type=Page); the body is a
56
+ // separate CustomPageModel row. Evidence: CustomPageController (@RequestMapping
57
+ // "/api/custom") — /create needs parentId + menuPointType (null → NPE/50000),
58
+ // and /page/content/* requires the AppFunction to already exist.
59
+ "custompage.list": Object.freeze({ scope: "app-tree custom page list (custom/page/list_by_appcode)", description: "列自定义页:按应用读 AppFunction(type=Page) 菜单节点,回 code/name/parentId/端点可见性(正文体积只回字节数)" }),
60
+ "custompage.get": Object.freeze({ scope: "app-tree custom page node (custom/get)", description: "读单个自定义页节点;content:true 时一并读 draftContent/publishedContent 原文(默认只回字节数)" }),
61
+ "custompage.create": Object.freeze({ scope: "app-tree custom page create (custom/create, AppFunction type=Page + CustomPageModel)", description: "新建自定义页节点:custom/create 需要 name/code/parentId/appCode 与 menuPointType(工具默认 CUSTOM_PAGE);建完再用 custompage.content.update 写正文" }),
62
+ "custompage.update": Object.freeze({ scope: "app-tree custom page update (custom/update)", description: "改自定义页节点(名称/父节点/可见性/图标);parentId 平台强制必填,省略会 50000,工具要求显式传入" }),
63
+ "custompage.content.get": Object.freeze({ scope: "custom page body read (custom/page/content/get)", description: "读页面正文 draftContent/publishedContent(可 part:draft|published|both 省输出)" }),
64
+ "custompage.content.update": Object.freeze({ scope: "custom page body draft write (custom/page/content/update)", description: "写页面正文草稿;publish:true 时同一调用再发布。草稿/发布是两个字段,门户读 publishedContent" }),
65
+ "custompage.content.publish": Object.freeze({ scope: "custom page body publish (custom/page/content/publish)", description: "发布页面正文(省略 content 则发布当前草稿),回读 publishedMatches 自证" }),
66
+ "custompage.delete": Object.freeze({ scope: "custom page delete (custom/delete)", description: "删除自定义页(已缺席即幂等跳过),回读 absent 自证" }),
47
67
  }),
48
68
  });
49
69
  export function assertFolderContract() {
@@ -55,5 +75,13 @@ export function assertFolderContract() {
55
75
  throw new Error("folder contract must pin the non-recursive delete refusal");
56
76
  if (!FOLDER_WIRE.tree.includes("NESTED"))
57
77
  throw new Error("folder contract must pin the nested tree shape");
78
+ // Custom pages: the two-row model (tree node + body row) and the NPE-on-null
79
+ // menuPointType are the facts a future edit must not lose.
80
+ if (!FOLDER_WIRE.customPage.includes("menuPointType"))
81
+ throw new Error("custom page contract must pin the required menuPointType");
82
+ if (!FOLDER_WIRE.customPage.includes("BIZ_SCHEMA_NOT_EXIST"))
83
+ throw new Error("custom page contract must pin node-before-body");
84
+ assertDescriptionCarries(FOLDER_LANE_CONTRACT, "custompage.create", ["parentId", "menuPointType", "content.update"]);
85
+ assertDescriptionCarries(FOLDER_LANE_CONTRACT, "custompage.content.update", ["publish", "publishedContent"]);
58
86
  }
59
87
  //# sourceMappingURL=folder-contract.js.map
@@ -12,5 +12,19 @@ export const FOLDER_COVERAGE = declareLaneCoverage(FOLDER_LANE_CONTRACT.lane, [
12
12
  claim("folder.delete", T["folder.delete"].scope),
13
13
  ],
14
14
  },
15
+ {
16
+ capability: "Custom pages (自定义页面): app-tree node + JS/HTML body (draft/publish)",
17
+ evidence: ["issue/2026-10-25-inspection-r15-omc-frictions.md", "packages/omc-cli/src/lanes/custom-page.ts"],
18
+ tools: [
19
+ claim("custompage.list", T["custompage.list"].scope),
20
+ claim("custompage.get", T["custompage.get"].scope),
21
+ claim("custompage.create", T["custompage.create"].scope),
22
+ claim("custompage.update", T["custompage.update"].scope),
23
+ claim("custompage.content.get", T["custompage.content.get"].scope),
24
+ claim("custompage.content.update", T["custompage.content.update"].scope),
25
+ claim("custompage.content.publish", T["custompage.content.publish"].scope),
26
+ claim("custompage.delete", T["custompage.delete"].scope),
27
+ ],
28
+ },
15
29
  ]);
16
30
  //# sourceMappingURL=folder-coverage.js.map
@@ -302,6 +302,21 @@ export function registerListViewLane(tools) {
302
302
  conditions,
303
303
  actionCodes: readArray(model, "queryActions").map((action) => action.actionCode).filter((value) => value !== undefined),
304
304
  buttonCount: readArray(model, "customButtons").length,
305
+ // r14 F5 / r11 #6: the default face exposed only `buttonCount`, so a
306
+ // caller could not prove WHICH buttons landed (it had to trust the
307
+ // configure readback or cross-check rule.list). Codes + bindings cost
308
+ // ~40 B per button and answer "did my buttons land?"; the full
309
+ // script/actionConfig payload stays behind `buttons:true`.
310
+ buttonCodes: readArray(model, "customButtons").map((button) => button.code).filter((value) => typeof value === "string"),
311
+ buttonBindings: readArray(model, "customButtons")
312
+ .filter((button) => typeof button.code === "string")
313
+ .map((button) => ({
314
+ code: button.code,
315
+ ...(button.bindAction !== undefined ? { bindAction: button.bindAction } : {}),
316
+ ...(button.targetCode !== undefined ? { targetCode: button.targetCode } : {}),
317
+ ...(button.targetModel !== undefined ? { targetModel: button.targetModel } : {}),
318
+ ...(button.targetForm !== undefined ? { targetForm: button.targetForm } : {}),
319
+ })),
305
320
  // Button scripts live in h_biz_button.actionConfig (a JSON STRING)
306
321
  // whose `scriptData` holds the source for bindAction=SCRIPT. Surfacing
307
322
  // them here removes the "listview.get → artifact → JSON.parse →
@@ -612,6 +612,8 @@ export function registerModelLane(tools) {
612
612
  blocker: "model-referenced-by",
613
613
  schemaCode: args.schemaCode,
614
614
  referencingModels,
615
+ deletionOrder: [...referencingModels.map((model) => model.schemaCode), args.schemaCode],
616
+ nextCommand: `omc call model.delete --args '${JSON.stringify({ schemaCode: referencingModels[0].schemaCode })}'`,
615
617
  message: `${args.schemaCode} 被模型 ${referencingModels.map((model) => model.schemaCode).join("、")} 的关联字段引用,不能删除(服务端 deleteFunctions 的异步拒绝已提前到调用时)。依赖倒序清理:先删引用方模型(或先删其关联字段),再删被引用模型;引用方物理删除的逐列异步级联需 40-60s,完成后再删本模型。`,
616
618
  });
617
619
  }
@@ -377,12 +377,22 @@ export function registerOrganizationLane(tools) {
377
377
  }
378
378
  }
379
379
  }
380
- if (users.length > 0)
380
+ if (users.length > 0) {
381
+ // Name every member and hand over a runnable transfer for the first one:
382
+ // "先看人再转出" left the caller to assemble the transfer args (item 9).
383
+ const members = users.map((row) => ({ id: row.id ?? row.userId ?? null, name: row.name ?? null, username: row.username ?? null }));
384
+ const parentId = walked.rows.find((row) => row.id === String(args.deptId))?.parentId ?? "";
385
+ const first = members.find((member) => typeof member.id === "string" && member.id.length > 0);
381
386
  throw new ToolRefusalError({
382
- status: "refused", blocker: "department-has-users", deptId: String(args.deptId), users: users.length,
383
- nextCommand: `omc call org.department.users --args '${JSON.stringify({ deptId: String(args.deptId) })}'`,
384
- message: `部门 ${args.deptId} 下还有 ${users.length} 名员工,平台拒绝删除(200002)。先用 org.department.users 看人,再用 org.user.transferDepartment 转出(平台不允许删带人的部门)。`,
387
+ status: "refused", blocker: "department-has-users", deptId: String(args.deptId), users: users.length, members,
388
+ nextCommand: first && parentId
389
+ ? `omc call org.user.transferDepartment --args '${JSON.stringify({ userId: first.id, departmentId: parentId })}'`
390
+ : `omc call org.department.users --args '${JSON.stringify({ deptId: String(args.deptId) })}'`,
391
+ message: `部门 ${args.deptId} 下还有 ${users.length} 名员工,平台拒绝删除(200002)。成员:${members.map((member) => `${member.name ?? "?"}(${member.id ?? "?"})`).join("、")}。` +
392
+ `平台不允许删带人的部门,也不提供"随部门级联转出";逐个 org.user.transferDepartment 转到 ${parentId ? `父部门 ${parentId}` : "目标部门"} 后再删。` +
393
+ `(force 只处理子部门,不搬人——搬人是组织语义决策,工具不替你做。)`,
385
394
  });
395
+ }
386
396
  await wireCall(session, { path: ENDPOINTS.organizationDepartmentDelete, query: { ...orgQuery(session), id: args.deptId } });
387
397
  return { performed: { errcode: 0 }, readback: { deptId: args.deptId, deleted: true, usersBeforeDelete: users.length, deletedChildren } };
388
398
  });
@@ -429,7 +439,21 @@ export function registerOrganizationLane(tools) {
429
439
  const userId = String(data?.id ?? "");
430
440
  if (!userId)
431
441
  throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "平台未返回用户 id;先 org.user.search 回读确认" });
432
- return { performed: { errcode: 0 }, readback: { userId, name: data?.name ?? args.name, username: data?.username ?? args.username, departmentId: data?.departmentId ?? args.departmentId } };
442
+ return {
443
+ performed: { errcode: 0 },
444
+ readback: {
445
+ userId,
446
+ name: data?.name ?? args.name,
447
+ username: data?.username ?? args.username,
448
+ departmentId: data?.departmentId ?? args.departmentId,
449
+ // 1.1.25 does not echo `active` on add and does not honour an update to
450
+ // it (r15 P7): the flag looks derived from account activation/binding.
451
+ // Say so, so nobody re-creates the user to "fix" it.
452
+ ...(args.active !== undefined
453
+ ? { activeWarning: `平台不回读 active(1.1.25 上它疑似由账号激活/绑定派生,不是可直接写的开关)。启停账号用 status=ENABLE/DISABLE/LOCK;不要为改 active 新建用户。` }
454
+ : {}),
455
+ },
456
+ };
433
457
  });
434
458
  writeTool(tools, "org.user.update", LANE, SCOPES.userUpdate, "改员工(`POST organization/user/modify`)。先 user/info 读现状再合并;工具强制补 `deptIds:[]` 等四个数组——平台 modified 对 null 的 deptIds 直接 NPE(50000,真机 1.1.25)。可改姓名/部门/手机/邮箱/角色/启停。", {
435
459
  userId: z.string().min(1),
@@ -220,13 +220,16 @@ export function registerRuleLifecycleLane(tools) {
220
220
  if (error instanceof WireError && error.kind === "business" && error.errcode === 550001)
221
221
  throw new ToolRefusalError({
222
222
  status: "refused",
223
- blocker: "rule-not-custom-trigger",
223
+ blocker: "rule-not-executable",
224
224
  schemaCode: args.schemaCode,
225
225
  ruleCode: args.ruleCode,
226
- nextCommand: `omc call rule.get --args '${JSON.stringify({ schemaCode: args.schemaCode, ruleCode: args.ruleCode })}'`,
227
- message: `平台 launcher 运行时没有注册规则 ${args.ruleCode}(550001 业务规则不存在)。\`rule.run\` 走的是「按钮/自定义触发」端点,` +
228
- `只认 CUSTOM_NODE 触发的规则;数据事件(Create/Update/…)的规则不会注册在这里。` +
229
- `要看规则图里有没有 CUSTOM_NODE,用 rule.get 看 nodes;要验证数据事件规则请走 data.submit/data.update,要验证按钮请绑到列表/表单按钮后在门户点击。`,
226
+ nextCommand: `omc call rule.enable --args '${JSON.stringify({ schemaCode: args.schemaCode, ruleCode: args.ruleCode })}'`,
227
+ message: `550001「业务规则不存在」在这里的真实含义是「该规则当前不可执行」,不是规则不存在,也不是「只认 CUSTOM_NODE」。` +
228
+ `反编译 8.6.26 的 BusinessRuleTableImpl#getExecutableBySchemaCodeAndCode 是唯一判定:` +
229
+ `① **DRAFT 行**必须存在;② DRAFT 行 enabled=true **且** status != DISABLED;③ 至少一条 PUBLISH 版本。` +
230
+ `rule.save --publish 只写 PUBLISH 行,**DRAFT 行的 status 仍是 DISABLED**——所以发布完直接 rule.run 必然 550001。` +
231
+ `修法:rule.enable(它先 publish 再调平台 /enable,把 DRAFT 行 status 置 ENABLED)。` +
232
+ `注意 rule.list 显示的是 PUBLISH 行的 enabled/status,看起来"已启用"但运行期读的是 DRAFT 行——不要用 list 判定可执行性。`,
230
233
  });
231
234
  throw error;
232
235
  }
@@ -979,6 +979,19 @@ export function registerRuntimeDataLane(tools) {
979
979
  isSimulative: z.boolean().optional(),
980
980
  ...readbackProjectionArgs,
981
981
  }, async (session, args) => {
982
+ // r13 #5: an EMPTY STRING was forwarded verbatim, so the platform answered
983
+ // 100002「参数为空」with no hint which parameter. Absent and blank are not
984
+ // the same intent: refuse the blank one by name, zero wire.
985
+ const blank = ["workItemId", "workflowInstanceId", "workflowCode"].filter((key) => typeof args[key] === "string" && args[key].trim().length === 0 && args[key].length > 0);
986
+ if (blank.length > 0)
987
+ throw new ToolRefusalError({
988
+ status: "refused",
989
+ blocker: "submit-blank-parameter",
990
+ schemaCode: args.schemaCode,
991
+ parameters: blank,
992
+ message: `这些参数传了空白字符串:${blank.join("、")}。平台会把这个空白当"参数为空"回 100002(SYS_PARAMETER_EMPTY),` +
993
+ `而错误里不会说是哪一个。要么给出真实 id,要么整个省略该键(省略 ≠ 传空串)。`,
994
+ });
982
995
  const hasWorkItem = typeof args.workItemId === "string" && args.workItemId.trim().length > 0;
983
996
  const hasInstance = typeof args.workflowInstanceId === "string" && args.workflowInstanceId.trim().length > 0;
984
997
  const targetObjectId = typeof args.bizObjectId === "string" && args.bizObjectId.trim() ? args.bizObjectId.trim() : null;
@@ -1215,7 +1228,9 @@ export function registerRuntimeDataLane(tools) {
1215
1228
  // An approval submit returns the status + the fields the caller submitted,
1216
1229
  // not the whole row: r12's 25 approvals were the largest read face at
1217
1230
  // 55.6 KB. Explicit summary/fields/full keep their contract.
1218
- const approvalDelta = hasWorkItem && args.summary !== true && args.full !== true && !(Array.isArray(args.fields) && args.fields.length > 0);
1231
+ // Pure-instance submits (workflowInstanceId without workItemId) go
1232
+ // through the same engine path and deserve the same delta (item 11).
1233
+ const approvalDelta = (hasWorkItem || hasInstance) && args.summary !== true && args.full !== true && !(Array.isArray(args.fields) && args.fields.length > 0);
1219
1234
  readback = {
1220
1235
  ...readback,
1221
1236
  workflowInstanceId: effectiveInstanceId,
@@ -2214,7 +2214,7 @@ export function registerWorkflowLane(tools) {
2214
2214
  const activityNames = Object.fromEntries(rawActivityRows
2215
2215
  .map((row) => [String(row.activityCode ?? ""), effectiveActivityName(row)])
2216
2216
  .filter(([code]) => code !== ""));
2217
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2217
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, rollbackHint: `本次改动的 before 快照已记录:omc rollback plan --target workflow:${args.schemaCode}/${args.code} 查看可回退项,omc rollback apply --target … --step <n> 执行(草稿回退后仍需 workflow.publish 才生效)`, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2218
2218
  }, (args) => {
2219
2219
  // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
2220
2220
  // governed pipeline — including the rollback before-capture read.
@@ -302,11 +302,17 @@ export async function cmdModelPurge(args) {
302
302
  let failed = instanceOutcome.failed;
303
303
  const unremovableInstances = instanceOutcome.unremovableInstances;
304
304
  // 2) the model — everything else cascades with it.
305
+ let referrers = [];
305
306
  if (!failed) {
306
307
  const outcome = await call("model.delete", { schemaCode });
307
308
  const readback = outcome.result.readback;
308
309
  const absent = readback?.absent === true || outcome.result.performed?.skipped === "already-absent";
309
- record(`model.delete:${schemaCode}`, outcome.ok || absent, outcome.ok || absent ? (readback ? { cascades: true, absent: readback.absent ?? null } : undefined) : { message: refusalText(outcome.result) });
310
+ referrers = Array.isArray(outcome.result.referencingModels)
311
+ ? outcome.result.referencingModels.map((row) => String(row.schemaCode ?? "")).filter((code) => code.length > 0)
312
+ : [];
313
+ record(`model.delete:${schemaCode}`, outcome.ok || absent, outcome.ok || absent
314
+ ? (readback ? { cascades: true, absent: readback.absent ?? null } : undefined)
315
+ : { message: refusalText(outcome.result), ...(referrers.length > 0 ? { referencedBy: referrers } : {}) });
310
316
  if (!outcome.ok && !absent)
311
317
  failed = true;
312
318
  }
@@ -321,7 +327,16 @@ export async function cmdModelPurge(args) {
321
327
  ...(failed
322
328
  ? {
323
329
  nextAction: "fix-and-rerun",
324
- message: "在失败的这一步停下(幂等,可修好后重跑同一条命令)。已成功的步骤不会重复破坏。",
330
+ ...(referrers.length > 0
331
+ ? {
332
+ blockedBy: referrers,
333
+ deletionOrder: [...referrers, schemaCode],
334
+ nextCommand: `omc model-purge --code ${referrers[0]} --purge-instances --apply`,
335
+ }
336
+ : {}),
337
+ message: referrers.length > 0
338
+ ? `${schemaCode} 仍被 ${referrers.join("、")} 的关联字段引用,模型不能删(平台 deleteFunctions 的异步拒绝已提前到调用时)。按 deletionOrder 先清引用方,再重跑本命令。`
339
+ : "在失败的这一步停下(幂等,可修好后重跑同一条命令)。已成功的步骤不会重复破坏。",
325
340
  }
326
341
  : unremovableInstances.length > 0
327
342
  ? {
@@ -855,7 +855,15 @@ export async function executeRuleSaveWrite(session, args) {
855
855
  ...(lintWarnings.length > 0 ? { lintWarnings } : {}),
856
856
  ...(lintHazards.length > 0 ? { lintHazards } : {}),
857
857
  customTrigger: customTriggerApplied,
858
- ...(customTriggerApplied ? {} : { customTriggerHint: "图中没有 CUSTOM_NODE 触发节点:rule.run(domain_service_runtime)只注册自定义触发规则,数据事件规则请在 submit/update 时触发或绑到列表/表单按钮" }),
858
+ // publish only mints the PUBLISH row; the runtime gate
859
+ // (BusinessRuleTableImpl#getExecutableBySchemaCodeAndCode) reads the
860
+ // DRAFT row's enabled+status, and no read face exposes that pair
861
+ // unambiguously (the list projection surfaces the PUBLISH row). So do
862
+ // NOT claim an `executable` boolean — state the precondition instead.
863
+ // Saying "published" without this is what cost r15 four rule.run attempts.
864
+ executableRequires: "rule.enable",
865
+ executableHint: "publish 只写 PUBLISH 行,不等于可执行:运行时按 DRAFT 行的 enabled + status 判定(BusinessRuleTableImpl#getExecutableBySchemaCodeAndCode),而 list 面显示的是 PUBLISH 行。用 rule.run / domain_service_runtime 前先 rule.enable(它 publish 后再调平台 /enable,把 DRAFT 行 status 置 ENABLED)。",
866
+ ...(customTriggerApplied ? {} : { customTriggerHint: "图中没有 CUSTOM_NODE 触发节点:门户按钮/流程按钮调用该规则时会按各自的触发面执行;rule.run 也能执行(它不要求 CUSTOM_NODE),执行时以 BUTTON 触发类型跑同一张图" }),
859
867
  id: effective?.id ?? undefined,
860
868
  ruleVersion: effective?.ruleVersion,
861
869
  latestPublished: effective?.latestPublished,
@@ -874,7 +882,7 @@ export async function executeRuleSaveWrite(session, args) {
874
882
  ...(lintWarnings.length > 0 ? { lintWarnings } : {}),
875
883
  ...(lintHazards.length > 0 ? { lintHazards } : {}),
876
884
  customTrigger: customTriggerApplied,
877
- ...(customTriggerApplied ? {} : { customTriggerHint: "图中没有 CUSTOM_NODE 触发节点:rule.run(domain_service_runtime)只注册自定义触发规则,数据事件规则请在 submit/update 时触发或绑到列表/表单按钮" }),
885
+ ...(customTriggerApplied ? {} : { customTriggerHint: "图中没有 CUSTOM_NODE 触发节点:门户按钮/流程按钮调用该规则时会按各自的触发面执行;rule.run 也能执行(它不要求 CUSTOM_NODE),执行时以 BUTTON 触发类型跑同一张图" }),
878
886
  id: effective?.id ?? undefined,
879
887
  ruleVersion: effective?.ruleVersion,
880
888
  latestPublished: effective?.latestPublished,
package/dist/tools.js CHANGED
@@ -5,6 +5,7 @@ import { registerEnvironmentLane } from "./lanes/environment.js";
5
5
  import { registerAppLane } from "./lanes/app.js";
6
6
  import { registerDictionaryLane } from "./lanes/dictionary.js";
7
7
  import { registerFolderLane } from "./lanes/folder.js";
8
+ import { registerCustomPageTools } from "./lanes/custom-page.js";
8
9
  import { registerModelLane } from "./lanes/model.js";
9
10
  import { registerFieldLane } from "./lanes/field.js";
10
11
  import { registerDataRuleLane } from "./lanes/datarule.js";
@@ -37,6 +38,7 @@ export function buildToolRegistry() {
37
38
  registerDictionaryLane(tools);
38
39
  registerModelLane(tools);
39
40
  registerFolderLane(tools);
41
+ registerCustomPageTools(tools);
40
42
  registerFieldLane(tools);
41
43
  registerDataRuleLane(tools);
42
44
  registerFormLane(tools);
package/dist/write.js CHANGED
@@ -85,8 +85,23 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload) {
85
85
  };
86
86
  if (error instanceof ToolRefusalError)
87
87
  throw error;
88
- if (error instanceof WireError)
88
+ if (error instanceof WireError) {
89
+ // authine-orm Serializer.update/delete throws "Object <id> is not
90
+ // serialized." when the entity handle was never loaded from the store.
91
+ // That is a PLATFORM object-state error, not a shape error — reported as
92
+ // `wire-business` the caller reads "my JSON is wrong" and retries the
93
+ // wrong thing (r12 A6: rule.save on a preset Create row, self-healed by
94
+ // delete+rebuild). Name the state and the two recoveries.
95
+ if (/is not serialized|does not serialized/i.test(error.message))
96
+ return {
97
+ status: "refused",
98
+ blocker: "orm-object-state",
99
+ errcode: error.errcode,
100
+ message: `${error.message}——这是平台 ORM 的对象状态错误(Serializer.update/delete 要求实体先被装载),不是入参或规则图的形状问题。` +
101
+ "同一份入参再保存一次通常即恢复。持续复现就换行:rule.delete 后重建(rule.save 会铸新记录),或用 rule.get 读回完整行再 rule.node.update 改一处——不要继续重试同一份 payload。",
102
+ };
89
103
  return { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message, ...(error.diagnostic ? { diagnostic: error.diagnostic } : {}) };
104
+ }
90
105
  throw error;
91
106
  }
92
107
  // A write tool that runs a step chain (field.update cascadeRepublish) may
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.90",
3
+ "version": "0.4.92",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,9 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@stalfh233/omc-fake-platform": "0.4.90",
53
+ "@stalfh233/omc-fake-platform": "0.4.92",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.90",
55
+ "omc-core": "0.4.92",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -60,8 +60,8 @@ only the section for the capability you need —
60
60
  ## 应用目录(分组)`folder`
61
61
 
62
62
  - **能做哪些事**:读应用树(目录 + 模型,递归展平带 `path/depth`)、建目录、改名/挂载、删除空目录;配合 `model.move` 把模型放进目录。
63
- - **工具**:`folder.tree`、`folder.create`、`folder.update`、`folder.delete`、`model.move`
64
- - **边界 / 陷阱**:`folder.create` 省略 `parentId` 建的是**顶层目录**(平台默认挂应用根);平台**不校验重名**(工具默认同父同名拒绝,并列同名传 `allowDuplicate:true`);`folder.delete` **非递归**(有子节点即拒 `folder-not-empty`);应用树是**嵌套** `children`,`folder.tree` 已展平并给 `path/depth`——只看顶层会误判"目录没建成"。模型进目录用 `model.move {schemaCode, folderId}`,`folderId` 必须是目录 **id**(给名字回 `folder-not-found` / 跨应用回 `folder-other-application`)。
63
+ - **工具**:`folder.tree`、`folder.create`、`folder.update`、`folder.delete`、`model.move`;自定义页面(同一棵应用树):`custompage.list`、`custompage.get`、`custompage.create`、`custompage.update`、`custompage.content.get`、`custompage.content.update`、`custompage.content.publish`、`custompage.delete`
64
+ - **边界 / 陷阱**:`folder.create` 省略 `parentId` 建的是**顶层目录**(平台默认挂应用根);平台**不校验重名**(工具默认同父同名拒绝,并列同名传 `allowDuplicate:true`);`folder.delete` **非递归**(有子节点即拒 `folder-not-empty`);应用树是**嵌套** `children`,`folder.tree` 已展平并给 `path/depth`——只看顶层会误判"目录没建成"。模型进目录用 `model.move {schemaCode, folderId}`,`folderId` 必须是目录 **id**(给名字回 `folder-not-found` / 跨应用回 `folder-other-application`)。**自定义页面是两行**:应用树节点(AppFunction type=Page,`custompage.create` 需要 `parentId`;`menuPointType` 平台必填,省略会 NPE→50000,工具默认 `CUSTOM_PAGE`)+ 页面正文(`draftContent`/`publishedContent`)。**必须先建节点再写正文**:`/page/content/*` 先按 code 查 AppFunction,缺节点回 `BIZ_SCHEMA_NOT_EXIST`。门户读的是 `publishedContent`——`content.update` 只写草稿,上线要 `content.publish`(或 update 传 `publish:true`)。页面 JS 若要复用平台对象,按在线 JS 的宿主对象约定写,别假设 `window.axios`。
65
65
 
66
66
  ## 数据字典 `dictionary`
67
67