@stalfh233/omc-cli 0.4.87 → 0.4.90

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.
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
+ import { collectOrgDepartments } from "./org-tree.js";
3
4
  import { capabilityArg, environmentArg } from "../args.js";
4
5
  import { governedRead } from "../read.js";
5
6
  import { ToolRefusalError } from "../tool-types.js";
@@ -732,6 +733,86 @@ function participantReferenceWarnings(template) {
732
733
  }
733
734
  return warnings;
734
735
  }
736
+ /**
737
+ * Role participants are filtered by `ouScope ∩ originator's department chain`.
738
+ * A role whose members all have an empty scope therefore resolves to NOBODY —
739
+ * but the workflow still publishes, the submit still answers 0, and the todo
740
+ * silently falls back to the originator (r9/r10 live; r15 lost 12 calls to it).
741
+ * Refuse BEFORE the update wire (read-only check, fail-open when the member
742
+ * list is unavailable) so the trap is named where the participant is authored.
743
+ */
744
+ async function assertRoleParticipantScopes(session, body) {
745
+ const rows = Array.isArray(body.activities)
746
+ ? body.activities
747
+ : body.activities && typeof body.activities === "object"
748
+ ? Object.values(body.activities)
749
+ : [];
750
+ const roles = [];
751
+ for (const activity of rows) {
752
+ if (String(activity.activityType ?? "") !== "PARTICIPANT")
753
+ continue;
754
+ const raw = activity.participant;
755
+ if (typeof raw !== "string" || !raw.trim().startsWith("["))
756
+ continue;
757
+ for (const segment of raw.split("+")) {
758
+ let parsed;
759
+ try {
760
+ parsed = JSON.parse(segment.trim());
761
+ }
762
+ catch {
763
+ continue;
764
+ }
765
+ if (!Array.isArray(parsed))
766
+ continue;
767
+ for (const entry of parsed) {
768
+ if (entry === null || typeof entry !== "object")
769
+ continue;
770
+ const record = entry;
771
+ if (String(record.type ?? record.participantType ?? "") !== "2")
772
+ continue;
773
+ const roleId = String(record.id ?? "");
774
+ if (roleId)
775
+ roles.push({ activityCode: String(activity.activityCode ?? ""), roleId, roleName: String(record.name ?? roleId) });
776
+ }
777
+ }
778
+ }
779
+ const problems = [];
780
+ for (const role of roles) {
781
+ let members;
782
+ try {
783
+ const data = (await wireCall(session, {
784
+ path: ENDPOINTS.organizationRoleUsers,
785
+ query: { corpId: session.enterpriseId(), roleId: role.roleId, page: "0", size: "500" },
786
+ })).data;
787
+ members = Array.isArray(data?.content) ? data.content : wireRows(data);
788
+ }
789
+ catch {
790
+ // A read failure must not block the write (fail open) — the platform
791
+ // will answer, and the participant warnings still ride the readback.
792
+ continue;
793
+ }
794
+ if (members.length === 0)
795
+ continue; // nothing to prove
796
+ const withScope = members.filter((member) => Array.isArray(member.ouScope) && member.ouScope.length > 0);
797
+ if (withScope.length === 0)
798
+ problems.push({ ...role, members: members.length });
799
+ }
800
+ if (problems.length === 0)
801
+ return;
802
+ const detail = problems.map((problem) => `${problem.activityCode} → 角色 ${problem.roleName}(${problem.roleId}) 的 ${problem.members} 名成员 ouScope 全为空`).join(";");
803
+ const first = problems[0];
804
+ throw new ToolRefusalError({
805
+ status: "refused",
806
+ blocker: "workflow-participant-role-scope-empty",
807
+ roles: problems,
808
+ nextCommand: `omc call org.role.users --args '${JSON.stringify({ roleId: first.roleId })}'`,
809
+ message: `节点参与者用了角色,但该角色成员的 ouScope 为空:${detail}。` +
810
+ `平台按「ouScope ∩ 发起人部门链」解析角色成员,空范围 = 永远解析不到人;流程仍会发布、submit 仍回 errcode 0,` +
811
+ `待办会**静默回退给发起人**(r9/r10/r15 真机)。` +
812
+ `先给成员补管理范围(部门节点):omc call org.role.setUserScope --args '{"roleId":"${first.roleId}","userId":"<成员recId>","scopes":[{"id":"<部门id>","name":"<部门名>","type":"1"}],"mode":"replace"}',` +
813
+ `或在 addUsers 时直接带 scopes。补完再重试本次 workflow.update。`,
814
+ });
815
+ }
735
816
  function isPlatformParticipantReference(participant) {
736
817
  if (/^\{[^}]+\}$/.test(participant))
737
818
  return true; // {Originator} keyword
@@ -2102,6 +2183,7 @@ export function registerWorkflowLane(tools) {
2102
2183
  await backfillDesignerDefaults(session, args.schemaCode, body, template);
2103
2184
  assertFirstActivityIsOriginator(template, "workflow.update", body);
2104
2185
  validateWorkflowRender(body, "workflow.update");
2186
+ await assertRoleParticipantScopes(session, body);
2105
2187
  // Live: workflow/update does NOT run the template validator — it stores a
2106
2188
  // refused participant silently and only /publish answers the
2107
2189
  // ValidateMessage[]. So the guard belongs on publish/bindRule, not here.
@@ -2553,6 +2635,7 @@ export function registerWorkflowLane(tools) {
2553
2635
  blocker: "workflow-has-instances",
2554
2636
  workflowCode: args.code,
2555
2637
  ...(pending > 0 ? { pendingWorkItems: pending } : {}),
2638
+ nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowCode: args.code })}'`,
2556
2639
  message: `流程 ${args.code} 还有流程实例,平台拒绝删除(402508)。**不要改用 model.delete 绕过**——那会把流程模板级联删掉,` +
2557
2640
  `留下指向空模板的待办,而平台全局待办搜索会给每条待办解析模板,**一条缺失就整页 NPE(50000),所有用户的待办列表一起坏掉**(r14 实测 8 连击,需维护者清库)。` +
2558
2641
  `正确顺序:① 先完结/清理实例的待办(workitem.list --workflowCode ${args.code} 看还有哪些在办);② 再 workflow.delete;③ 最后才 model.delete。`,
@@ -3028,50 +3111,15 @@ export function registerWorkflowLane(tools) {
3028
3111
  lane: WORKFLOW_LANE,
3029
3112
  scope: WORKFLOW_TOOLS["org.department.list"].scope,
3030
3113
  description: WORKFLOW_TOOLS["org.department.list"].description,
3031
- inputSchema: { environment: environmentArg, parentId: z.string().min(1).optional().describe("只列出该部门下的子树(默认从根递归展开整棵部门树)"), capability: capabilityArg },
3114
+ inputSchema: { environment: environmentArg, parentId: z.string().min(1).optional().describe("只列出该部门及其下属(BFS 展开;默认从根展开整棵部门树)"), capability: capabilityArg },
3032
3115
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
3033
- // `/department/tree` semantics (live 2026-10-22, 1.1.25): no `deptIds`
3034
- // → the ROOTS; with `deptIds=<id>` → that department's WHOLE SUBTREE
3035
- // (not one level). So: roots once, then one subtree call per root.
3116
+ // `/department/tree` semantics (re-verified r15, 1.1.25): no `deptIds`
3117
+ // → ROOTS; `deptIds=<id>` → DIRECT CHILDREN. The old "whole subtree"
3118
+ // reading hid third-level departments (电气组) from this list. The shared
3119
+ // walker does a bounded BFS.
3036
3120
  const requested = args.parentId;
3037
- const fetchLevel = async (deptId) => {
3038
- const query = deptId === undefined ? {} : { deptIds: deptId };
3039
- const data = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentTree, query })).data;
3040
- return Array.isArray(data?.departmentList) ? data.departmentList : wireRows(data);
3041
- };
3042
- const levels = [];
3043
- const seen = new Set();
3044
- const push = (rows) => {
3045
- for (const row of rows) {
3046
- const id = String(row.id ?? "");
3047
- if (id === "" || seen.has(id))
3048
- continue;
3049
- seen.add(id);
3050
- levels.push(row);
3051
- }
3052
- };
3053
- if (requested === undefined) {
3054
- push(await fetchLevel(undefined));
3055
- }
3056
- else {
3057
- // `deptIds=<id>` returns the subtree BELOW that department, excluding
3058
- // it (live 2026-10-22). A caller asking for `parentId` expects that
3059
- // department itself too, so add it from the authoritative record.
3060
- try {
3061
- const self = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { deptId: requested } })).data;
3062
- if (self)
3063
- push([self]);
3064
- }
3065
- catch {
3066
- // an unknown id simply yields an empty list
3067
- }
3068
- push(await fetchLevel(requested));
3069
- }
3070
- for (const root of levels.filter((row) => String(row.parentId ?? "") === "" || requested !== undefined).map((row) => String(row.id ?? ""))) {
3071
- if (seen.size >= 500)
3072
- break;
3073
- push(await fetchLevel(root));
3074
- }
3121
+ const walked = await collectOrgDepartments(session, requested === undefined ? {} : { rootId: requested });
3122
+ const levels = walked.rows;
3075
3123
  const departments = levels.map((dept) => ({
3076
3124
  id: dept.id ?? null,
3077
3125
  name: dept.name ?? null,
@@ -3112,33 +3160,10 @@ export function registerWorkflowLane(tools) {
3112
3160
  if (!keyword) {
3113
3161
  return governedRead(ctx, self, args, async (session) => {
3114
3162
  const orgQuery = { corpId: session.enterpriseId() };
3115
- const seenDepartments = new Set();
3116
- const order = [];
3117
- const pushRows = (rows) => {
3118
- for (const row of rows) {
3119
- const id = String(row.id ?? "");
3120
- if (id === "" || seenDepartments.has(id))
3121
- continue;
3122
- seenDepartments.add(id);
3123
- order.push(id);
3124
- }
3125
- };
3126
- // `/department/tree` answers `{departmentList:[...]}`, not a bare array;
3127
- // `wireRows` only understands arrays/page envelopes.
3128
- const treeRows = async (deptId) => {
3129
- const data = (await wireCall(session, {
3130
- path: ENDPOINTS.organizationDepartmentTree,
3131
- query: deptId === undefined ? orgQuery : { ...orgQuery, deptIds: deptId },
3132
- })).data;
3133
- return Array.isArray(data?.departmentList) ? data.departmentList : [];
3134
- };
3135
- pushRows(await treeRows());
3136
- // `deptIds` returns the subtree BELOW that department; walk the roots.
3137
- for (const rootId of [...order]) {
3138
- if (order.length >= 200)
3139
- break;
3140
- pushRows(await treeRows(rootId));
3141
- }
3163
+ // r15: `deptIds` answers DIRECT CHILDREN only, so the earlier
3164
+ // roots + one-level walk missed users in third-level departments.
3165
+ const walked = await collectOrgDepartments(session, { limit: 200 });
3166
+ const order = walked.rows.map((row) => row.id);
3142
3167
  const seenUsers = new Map();
3143
3168
  let departmentsScanned = 0;
3144
3169
  for (const deptId of order.slice(0, 200)) {
@@ -3163,7 +3188,7 @@ export function registerWorkflowLane(tools) {
3163
3188
  count: users.length,
3164
3189
  total: users.length,
3165
3190
  departmentsScanned,
3166
- ...(order.length > 200 ? { truncated: true, note: `部门树超过 200 个,只聚合了前 200 个;用 wd 关键字精确检索` } : {}),
3191
+ ...(walked.truncated || order.length >= 200 ? { truncated: true, note: `部门树超过 200 个,只聚合了前 200 个;用 wd 关键字精确检索` } : {}),
3167
3192
  users,
3168
3193
  hint: "无关键字=列全员(按部门树聚合)。平台 user/search 的 wd 不支持空串(10005),所以这条路径走 department/list_user;只要某部门的人用 org.department.users。",
3169
3194
  };
@@ -0,0 +1,343 @@
1
+ /**
2
+ * `omc model-purge` — one command erases a model and everything hanging off it.
3
+ *
4
+ * Why this exists (the most expensive lesson of rounds 12–14): every "dirty
5
+ * sandbox" that derailed a probe was MY non-atomic cleanup, not the platform's
6
+ * fault.
7
+ *
8
+ * r12 deleted a department with SQL → the user cache kept the dead id →
9
+ * every `data.submit` answered a phantom 40009
10
+ * r12 cleared `h_biz_schema` without `h_app_function` → an orphan model that
11
+ * no tool could delete (the platform has no path for it)
12
+ * r14 deleted a model that still had instances → the cascade removed the
13
+ * workflow template but left `biz_workitem` rows pointing at nothing →
14
+ * the platform's GLOBAL todo search NPE'd for everyone (8× 50000)
15
+ *
16
+ * The platform's dependency order is real and each violation produces a
17
+ * different, confusing failure. Encoding the order ONCE — verified by readback
18
+ * at every step — removes the class of bug instead of the instance.
19
+ *
20
+ * Order (dependents first):
21
+ * list views → business rules → data rules → workflow instances →
22
+ * workflow → model (fields/forms/views cascade)
23
+ */
24
+ import { argvValue } from "./args.js";
25
+ import { runToolCall } from "./call.js";
26
+ import { CLI_EXIT_CODES, classifyResult, envelope, refusalText, requestId } from "./cli-contract.js";
27
+ import { writeEnvelope } from "./cli-output.js";
28
+ import { ENDPOINTS } from "./endpoints.js";
29
+ import { wireCall, wireRows } from "./wire.js";
30
+ import { SessionManager } from "./session-manager.js";
31
+ import { ConfigStore, defaultPaths } from "./config-store.js";
32
+ async function sessionFor(environment) {
33
+ const store = new ConfigStore(defaultPaths());
34
+ const sessions = new SessionManager(store);
35
+ return sessions.session(environment);
36
+ }
37
+ /**
38
+ * Read-only discovery: everything the purge would remove, in dependency order.
39
+ * `session` is injectable so tests can plan against a fake platform; the CLI
40
+ * passes nothing and gets the environment's real session.
41
+ */
42
+ export async function planPurge(options, injected) {
43
+ const session = injected ?? (await sessionFor(options.environment));
44
+ let modelExists = true;
45
+ try {
46
+ const record = (await wireCall(session, { path: ENDPOINTS.modelGet, query: { schemaCode: options.schemaCode } })).data;
47
+ modelExists = record !== null && record !== undefined;
48
+ }
49
+ catch {
50
+ modelExists = false;
51
+ }
52
+ const listViews = [];
53
+ try {
54
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.listQueryList, query: { schemaCode: options.schemaCode, clientType: "PC" } })).data);
55
+ for (const row of rows) {
56
+ const code = String(row.code ?? row.queryCode ?? "");
57
+ if (code)
58
+ listViews.push(code);
59
+ }
60
+ }
61
+ catch {
62
+ // A missing list-view face must not block the plan.
63
+ }
64
+ const businessRules = [];
65
+ try {
66
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.runtimeBusinessRuleListBySchemaCode, query: { schemaCode: options.schemaCode } })).data);
67
+ for (const row of rows) {
68
+ // The platform ships seven SYSTEM rules per model (Create/Update/Delete/
69
+ // Load/GetList/Available/Cancel) flagged `defaultRule:true` on the wire
70
+ // (`rule.list` projects that as `preset`). They are part of the model and
71
+ // cascade with it — deleting them first is noise at best, a broken model if
72
+ // the delete is partial. Only caller-authored rules go first.
73
+ if (row.defaultRule === true || row.preset === true)
74
+ continue;
75
+ const code = String(row.code ?? row.ruleCode ?? "");
76
+ if (code)
77
+ businessRules.push(code);
78
+ }
79
+ }
80
+ catch {
81
+ // ditto
82
+ }
83
+ let workflowCode;
84
+ let instanceCount = 0;
85
+ let pendingWorkItems = 0;
86
+ const instanceIds = new Set();
87
+ try {
88
+ const template = (await wireCall(session, { path: ENDPOINTS.workflowGetBySchema, query: { schemaCode: options.schemaCode } })).data;
89
+ const code = template && typeof template.workflowCode === "string" ? template.workflowCode : "";
90
+ if (code) {
91
+ workflowCode = code;
92
+ // `/runtime/workflow/list_instances` ALWAYS sets `currentUserId`, and the
93
+ // server adds `state != DRAFT` for any non-empty current user
94
+ // (BizWorkflowinstanceServiceImpl#toSearchListWorkflowInstanceCriterion).
95
+ // A `data.save {workflowCode}` starter is exactly a DRAFT instance, so the
96
+ // plain instance list answers 0 while its start work item sits in
97
+ // `biz_workitem` — the r14 orphan disease. Discover DRAFT instances
98
+ // through their work items; the instance list still covers the rest.
99
+ try {
100
+ const page = (await wireCall(session, {
101
+ path: ENDPOINTS.workflowListInstances,
102
+ query: { workflowCode: code, page: "0", size: "200" },
103
+ })).data;
104
+ const rows = Array.isArray(page?.content) ? page.content : wireRows(page);
105
+ for (const row of rows) {
106
+ const id = String(row.id ?? row.workflowInstanceId ?? "");
107
+ if (id)
108
+ instanceIds.add(id);
109
+ }
110
+ const total = Number(page?.totalElements ?? rows.length);
111
+ if (Number.isFinite(total) && total > rows.length)
112
+ instanceCount = total;
113
+ }
114
+ catch {
115
+ // A missing instance face must not block the plan; work items still do.
116
+ }
117
+ // Work items are the authoritative source for open (and DRAFT) instances:
118
+ // `list_workitems` REQUIRES a status (402504 without one), so ask for each
119
+ // open state and collect the instance ids it names.
120
+ for (const status of ["DRAFT", "PENDING", "INPROGRESS", "NOTSTARTED"]) {
121
+ try {
122
+ const wip = (await wireCall(session, {
123
+ path: ENDPOINTS.workflowListWorkItems,
124
+ query: { workflowCode: code, status, page: "0", size: "200" },
125
+ })).data;
126
+ const rows = Array.isArray(wip?.content) ? wip.content : wireRows(wip);
127
+ for (const row of rows) {
128
+ const id = String(row.instanceId ?? row.workflowInstanceId ?? "");
129
+ if (id)
130
+ instanceIds.add(id);
131
+ }
132
+ const total = Number(wip?.totalElements ?? 0);
133
+ if (Number.isFinite(total))
134
+ pendingWorkItems += total;
135
+ }
136
+ catch {
137
+ // One unreadable status must not hide the others.
138
+ }
139
+ }
140
+ instanceCount = Math.max(instanceCount, instanceIds.size);
141
+ }
142
+ }
143
+ catch {
144
+ // No workflow is a normal state.
145
+ }
146
+ const steps = [];
147
+ if (instanceCount > 0)
148
+ steps.push(options.purgeInstances ? `workflow.delete_instance ×${instanceCount}` : `⛔ ${instanceCount} 个流程实例(需 --purge-instances)`);
149
+ steps.push(`model.delete ${options.schemaCode}(级联带走:${[
150
+ workflowCode ? `流程 ${workflowCode}` : undefined,
151
+ listViews.length > 0 ? `${listViews.length} 个列表视图` : undefined,
152
+ businessRules.length > 0 ? `${businessRules.length} 条自建规则` : undefined,
153
+ "字段/表单/计算规则",
154
+ ].filter(Boolean).join("、")})`);
155
+ const blockers = [];
156
+ if (instanceCount > 0 && !options.purgeInstances)
157
+ blockers.push(`${instanceCount} 个流程实例存在:workflow.delete 会回 402508。` +
158
+ `**不要直接用 model.delete 绕过**——那会级联删掉流程模板、留下指向空模板的待办,` +
159
+ `平台全局待办搜索会给每条待办解析模板,一条缺失就整页 NPE(50000,影响所有用户)。` +
160
+ `加 --purge-instances 让本命令先逐个删实例。`);
161
+ return {
162
+ schemaCode: options.schemaCode,
163
+ modelExists,
164
+ listViews,
165
+ businessRules,
166
+ ...(workflowCode !== undefined ? { workflowCode } : {}),
167
+ instanceCount,
168
+ pendingWorkItems,
169
+ instanceIds: [...instanceIds].slice(0, 50),
170
+ steps,
171
+ blockers,
172
+ };
173
+ }
174
+ /**
175
+ * Delete the instances a purge must remove. Split out so the r15 case — a stale
176
+ * instance whose bizObject is gone makes `delete_instance` answer 50000 — is
177
+ * testable without a live platform. Returns the steps it produced; `failed`
178
+ * means an instance with OPEN work items could not be removed (the only case
179
+ * that must stop the cascade).
180
+ */
181
+ export async function deleteInstancesForPurge(session, workflowCode, instanceIds) {
182
+ const steps = [];
183
+ const unremovableInstances = [];
184
+ let failed = false;
185
+ if (workflowCode === undefined)
186
+ return { failed, steps, unremovableInstances };
187
+ const openWorkItemsFor = async (instanceId) => {
188
+ let total = 0;
189
+ for (const status of ["DRAFT", "PENDING", "INPROGRESS", "NOTSTARTED"]) {
190
+ try {
191
+ const page = (await wireCall(session, {
192
+ path: ENDPOINTS.workflowListWorkItems,
193
+ query: { workflowCode, status, page: "0", size: "200" },
194
+ })).data;
195
+ const rows = Array.isArray(page?.content) ? page.content : wireRows(page);
196
+ total += rows.filter((row) => String(row.instanceId ?? row.workflowInstanceId ?? "") === instanceId).length;
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ return total;
203
+ };
204
+ for (const id of instanceIds) {
205
+ if (failed)
206
+ break;
207
+ try {
208
+ const deleted = (await wireCall(session, { path: ENDPOINTS.workflowDeleteInstance, method: "POST", query: { workflowInstanceId: id } })).data;
209
+ steps.push({ step: `workflow.delete_instance:${id}`, status: deleted === true ? "ok" : "failed", ...(deleted === true ? {} : { detail: { note: "平台返回 false(可能已完结或无权),继续" } }) });
210
+ }
211
+ catch (error) {
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ const openItems = await openWorkItemsFor(id);
214
+ if (openItems === 0) {
215
+ unremovableInstances.push({ id, reason: "delete_instance 50000 且该实例没有在办待办(平台在业务对象缺失时 NPE)" });
216
+ steps.push({ step: `workflow.delete_instance:${id}`, status: "skipped", detail: { code: "platform-orphan-instance", message } });
217
+ continue;
218
+ }
219
+ steps.push({ step: `workflow.delete_instance:${id}`, status: "failed", detail: { message, ...(openItems === null ? { openWorkItems: "unknown" } : { openWorkItems: openItems }) } });
220
+ failed = true;
221
+ }
222
+ }
223
+ return { failed, steps, unremovableInstances };
224
+ }
225
+ export async function cmdModelPurge(args) {
226
+ const request = requestId();
227
+ const flag = (name) => argvValue(args, name);
228
+ const schemaCode = flag("--code") ?? flag("--schema") ?? flag("--schema-code");
229
+ const environment = flag("--env") ?? flag("--environment");
230
+ const apply = args.includes("--apply");
231
+ const purgeInstances = args.includes("--purge-instances");
232
+ const usage = [
233
+ "用法:omc model-purge --code <模型code> [--purge-instances] [--env <环境>] [--apply]",
234
+ " 一条命令按依赖倒序清掉:列表视图 → 自建业务规则 → 流程实例 → 流程 → 模型(字段/表单/计算规则/预置规则随模型级联)。",
235
+ " 默认 dry-run(只列计划,不写)。--purge-instances 才删流程实例(workflow.delete 遇到实例会 402508)。",
236
+ " 每步回读确认;某步失败即停并说明已完成到哪一步(幂等,可修好后重跑)。",
237
+ ].join("\n");
238
+ const invalid = (message) => {
239
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: `${message}\n${usage}`, retryable: false }));
240
+ return CLI_EXIT_CODES.invalidInput;
241
+ };
242
+ if (!schemaCode)
243
+ return invalid("--code 必填");
244
+ const options = { schemaCode, apply, purgeInstances, ...(environment !== undefined ? { environment } : {}) };
245
+ let plan;
246
+ try {
247
+ plan = await planPurge(options);
248
+ }
249
+ catch (error) {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ writeEnvelope(envelope("error", {}, request, { code: "internal", message: `清理计划读取失败:${message}`, retryable: false }));
252
+ return CLI_EXIT_CODES.internal;
253
+ }
254
+ if (!apply) {
255
+ writeEnvelope(envelope("ok", {
256
+ status: plan.blockers.length > 0 ? "blocked" : "dry-run",
257
+ ...plan,
258
+ ...(plan.blockers.length > 0
259
+ ? {}
260
+ : { note: "dry-run 只列计划、不写。--apply 按上面的顺序执行;每步回读确认。" }),
261
+ }, request));
262
+ return CLI_EXIT_CODES.ok;
263
+ }
264
+ // Refuse rather than do the dangerous half: deleting the model with live
265
+ // instances is exactly the mistake that poisoned r14's todo search.
266
+ if (plan.blockers.length > 0) {
267
+ writeEnvelope(envelope("refused", { status: "refused", blocker: "purge-blocked", ...plan, message: plan.blockers.join(" ") }, request, {
268
+ code: "policy-refused",
269
+ message: plan.blockers.join(" "),
270
+ retryable: false,
271
+ }));
272
+ return CLI_EXIT_CODES.policyRefused;
273
+ }
274
+ const steps = [];
275
+ const session = await sessionFor(environment);
276
+ const call = async (tool, toolArgs) => {
277
+ const outcome = await runToolCall(tool, { args: toolArgs, requestId: request, ...(environment !== undefined ? { environment } : {}), print: () => undefined });
278
+ return { ok: outcome.exitCode === 0, result: outcome.result };
279
+ };
280
+ const record = (step, ok, detail) => {
281
+ steps.push({ step, status: ok ? "ok" : "failed", ...(detail ? { detail } : {}) });
282
+ };
283
+ // The whole order is TWO steps, and that is the point.
284
+ //
285
+ // Round 13/14 tried: views → rules → instances → workflow → model. It
286
+ // deadlocks. A list view bound to the workflow cannot be deleted (the platform
287
+ // refuses to remove the LAST visible view, 306103) and the workflow cannot be
288
+ // deleted while that view binds it (303024) — a cycle no ordering escapes.
289
+ //
290
+ // `model.delete` already cascades the template, the views, the fields, the
291
+ // forms and the rules. The ONLY thing it does not clean is `biz_workitem`,
292
+ // which is exactly what poisons the global todo search. So: delete the
293
+ // instances first (that is the dangerous half), then let the model cascade
294
+ // take everything else.
295
+ // 1) instances — required before the model goes, or their work items outlive
296
+ // their template (the r14 50000). DRAFT instances only surface through
297
+ // their work items (r15: list_instances hides DRAFT), so the plan's union
298
+ // is authoritative; a stale instance with no open todo is skipped and
299
+ // reported instead of deadlocking the purge.
300
+ const instanceOutcome = await deleteInstancesForPurge(session, plan.workflowCode, plan.instanceIds);
301
+ steps.push(...instanceOutcome.steps);
302
+ let failed = instanceOutcome.failed;
303
+ const unremovableInstances = instanceOutcome.unremovableInstances;
304
+ // 2) the model — everything else cascades with it.
305
+ if (!failed) {
306
+ const outcome = await call("model.delete", { schemaCode });
307
+ const readback = outcome.result.readback;
308
+ 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
+ if (!outcome.ok && !absent)
311
+ failed = true;
312
+ }
313
+ const status = failed ? "partial" : "ok";
314
+ const result = {
315
+ status,
316
+ schemaCode,
317
+ ...(plan.workflowCode !== undefined ? { workflowCode: plan.workflowCode } : {}),
318
+ deletedInstances: steps.filter((step) => step.step.startsWith("workflow.delete_instance:") && step.status === "ok").length,
319
+ ...(unremovableInstances.length > 0 ? { unremovableInstances } : {}),
320
+ steps,
321
+ ...(failed
322
+ ? {
323
+ nextAction: "fix-and-rerun",
324
+ message: "在失败的这一步停下(幂等,可修好后重跑同一条命令)。已成功的步骤不会重复破坏。",
325
+ }
326
+ : unremovableInstances.length > 0
327
+ ? {
328
+ cleanupWarning: `${unremovableInstances.length} 条停滞实例无法通过平台 REST 删除(delete_instance 50000:业务对象已不存在,平台 NPE)。` +
329
+ "它们没有在办待办,所以不会污染全局待办搜索;模型已按其能力级联清理。" +
330
+ "残留的 `biz_workflow_instance` 行需要维护者清库(平台不提供强制删除面);再次用同 code 建流程前先清,否则新实例会继承这些行。",
331
+ maintainerAction: `DELETE FROM biz_workflow_instance WHERE id IN (${unremovableInstances.map((item) => `'${item.id}'`).join(", ")});`,
332
+ }
333
+ : {
334
+ note: "已清理:流程实例逐个删除 → model.delete 级联带走流程模板/列表视图/字段/表单/业务规则。" +
335
+ "model.delete 的异步级联约 40-60s,稍后仍能读到模型记录属正常残留,重跑本命令即可(幂等)。" +
336
+ "**这就是 r12–r14 三次脏沙盒的根治**:实例先走,就不会留下指向空模板的待办。",
337
+ }),
338
+ };
339
+ const classified = classifyResult(status === "partial" ? { ...result, requested: steps.length, succeeded: steps.filter((step) => step.status !== "failed").length } : result);
340
+ writeEnvelope(envelope(classified.status, result, request, classified.error));
341
+ return failed ? 1 : 0;
342
+ }
343
+ //# sourceMappingURL=model-purge.js.map
@@ -53,6 +53,37 @@ export function projectJsonPath(value, path) {
53
53
  }
54
54
  return { missed: false, value: current };
55
55
  }
56
+ export function projectJsonPathFanout(value, path) {
57
+ const marker = path.indexOf("[]");
58
+ if (marker < 0) {
59
+ const projected = projectJsonPath(value, path);
60
+ return projected.missed
61
+ ? { values: [], missed: true, ...(projected.at !== undefined ? { at: projected.at } : {}), ...(projected.available !== undefined ? { available: projected.available } : {}), missedEntries: 0 }
62
+ : { values: [projected.value], missed: false, missedEntries: 0 };
63
+ }
64
+ const before = path.slice(0, marker);
65
+ const after = path.slice(marker + 2).replace(/^[./]+/, "");
66
+ const base = projectJsonPath(value, before);
67
+ if (base.missed)
68
+ return { values: [], missed: true, ...(base.at !== undefined ? { at: base.at } : {}), ...(base.available !== undefined ? { available: base.available } : {}), missedEntries: 0 };
69
+ if (!Array.isArray(base.value))
70
+ return { values: [], missed: true, at: before || "$", available: `该层不是数组(是 ${base.value === null ? "null" : typeof base.value});[] 只能映射数组,如 items[].fields.planNo`, missedEntries: 0 };
71
+ const values = [];
72
+ let missedEntries = 0;
73
+ for (const entry of base.value) {
74
+ const projected = projectJsonPathFanout(entry, after);
75
+ values.push(...projected.values);
76
+ if (projected.missed)
77
+ missedEntries += 1;
78
+ }
79
+ // Every element missing means the path is wrong for this shape (not a sparse
80
+ // array) — report the miss so the caller does not read an empty list as data.
81
+ if (values.length === 0 && missedEntries > 0) {
82
+ const first = projectJsonPathFanout(base.value[0], after);
83
+ return { values: [], missed: true, ...(first.at !== undefined ? { at: `${before}[].${first.at}` } : {}), ...(first.available !== undefined ? { available: first.available } : {}), missedEntries };
84
+ }
85
+ return { values, missed: false, ...(missedEntries > 0 ? { missedEntries } : { missedEntries: 0 }) };
86
+ }
56
87
  /** Collect every value of a repeatable flag (`--extract a --extract b`). */
57
88
  function argvValues(args, name) {
58
89
  const values = [];
@@ -116,6 +147,15 @@ export function projectResultExtract(result, paths, keysOnly) {
116
147
  }
117
148
  if (paths.length > 0) {
118
149
  out.extract = paths.map((path) => {
150
+ // `items[].fields.planNo` maps over every row; a plain path stays a single
151
+ // value (issue 2026-10-25 §8). Both faces share one shape so a caller can
152
+ // switch between `--extract`/`--jq` without relearning the envelope.
153
+ if (path.includes("[]")) {
154
+ const fanout = projectJsonPathFanout(source, path);
155
+ return fanout.missed
156
+ ? { path, content: null, pathMiss: true, missedAt: fanout.at, available: fanout.available, missedEntries: fanout.missedEntries }
157
+ : { path, content: fanout.values, mapped: fanout.values.length, ...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries } : {}) };
158
+ }
119
159
  const projected = projectJsonPath(source, path);
120
160
  return projected.missed
121
161
  ? { path, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }
@@ -46,6 +46,9 @@ const ruleGraph = Object.freeze({
46
46
  const ruleBranchGraph = Object.freeze({
47
47
  id: "business-rule-branch-conditional",
48
48
  title: "金额分级:BRANCH 判断 + 两条 UPDATE 分支(真机验证)",
49
+ // Intent words, so `omc next "创建时如果 X 则赋值 Y 否则 Z"` finds this shape
50
+ // instead of falling back to a bare ladder (r14 hand-built it by hand).
51
+ aliases: ["条件赋值", "条件分支", "如果则否则", "大于则", "规则分支", "赋值规则", "if else"],
49
52
  status: "requires-prior-readback",
50
53
  tool: "rule.save",
51
54
  args: {
@@ -21,6 +21,21 @@ const listViewConfigure = Object.freeze({
21
21
  refusalRecovery: [
22
22
  { code: "listview-columns-unknown-field", action: "字段未发布或名字写错;先 field.create + field.publish 或改引用" },
23
23
  { code: "listview-not-found", action: "默认视图应已存在;用 listview.list 确认 code,必要时 listview.create" },
24
+ // r14 (2026-10-24): the probe spent calls guessing `targetCode` for a custom
25
+ // button because the recovery list only covered columns and not-found.
26
+ {
27
+ code: "form-buttons-invalid",
28
+ action: "按钮形状不合格。targetCode 的语义按 bindAction 决定:**BUSINESS_RULE → 业务规则 code**(rule.list 的 code,如 Create/Update 或自建规则);**FLOW → 流程 code**(workflow.get_published 的 workflowCode);SCRIPT → scriptData(也可用 buttons[].script 简写);ADD_DATA/FORM → targetModel + targetForm(工具会展开 schemaCodeVal/formListData)。name/code/useLocation/bindAction/operateType 必填。",
29
+ },
30
+ {
31
+ code: "listview-button-target-unresolved",
32
+ action: "ADD_DATA/FORM 按钮的 targetModel 在应用树里没有节点,或其默认表单未发布。先确认目标模型存在(discover_environment / model.list)且 form.publish 过;只想触发规则/流程时不要用 ADD_DATA,改用 BUSINESS_RULE/FLOW + targetCode。",
33
+ },
34
+ {
35
+ code: "listview-buttons-ambiguous",
36
+ action: "buttons 与 buttonScripts 不能同时传:整表重配用 buttons;只改脚本用 buttonScripts[{code,script}]。",
37
+ },
38
+ { code: "listview-button-absent", action: "该视图没有这个自定义按钮;先用 listview.configure 的 buttons 建(bindAction:SCRIPT),再改脚本。" },
24
39
  ...commonRecovery,
25
40
  ],
26
41
  requiresEnvironmentDerivedIds: false,