@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,1054 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { FilterDslError, injectPropertyTypes, toQueryCondition } from "../data-list-filter.js";
4
+ import { ENDPOINTS } from "../endpoints.js";
5
+ import { capabilityArg, environmentArg } from "../args.js";
6
+ import { governedRead } from "../read.js";
7
+ import { ToolInputError, ToolRefusalError } from "../tool-types.js";
8
+ import { writeTool } from "../write.js";
9
+ import { defineTool } from "../tool-types.js";
10
+ import { wireCall, wireRows, WireError } from "../wire.js";
11
+ import { CHILD_TABLE_PROPERTY_TYPE, runtimeWriteWithAppMapping } from "../runtime-support.js";
12
+ import { getReplayToken, searchWorkItems, wireString } from "../workflow-support.js";
13
+ import { ENGINE_OWNED_KEYS, RUNTIME_DATA_LANE_CONTRACT, RUNTIME_DATA_WIRE, runtimeFormDataArg, } from "./runtime-data-contract.js";
14
+ const RUNTIME_LANE = RUNTIME_DATA_LANE_CONTRACT.lane;
15
+ const RUNTIME_TOOLS = RUNTIME_DATA_LANE_CONTRACT.tools;
16
+ /**
17
+ * Resolve a model's single published workflow code, or null when the model has
18
+ * no workflow. A workflow-backed data submit must START the workflow
19
+ * (runtime/form/submit formType=1 + workflowCode), not merely save a sheet row
20
+ * (formType=2): only the former creates the workflow instance and runs node
21
+ * routing / approval. Detection via workflow/isworkflow (WorkflowTemplateController
22
+ * :1338), code via workflow/getWorkflow (returns the published template).
23
+ */
24
+ async function resolvePublishedWorkflowCode(session, schemaCode) {
25
+ const isWorkflow = (await wireCall(session, { path: ENDPOINTS.workflowIsWorkflow, query: { schemaCode } })).data === true;
26
+ if (!isWorkflow)
27
+ return null;
28
+ const template = (await wireCall(session, { path: ENDPOINTS.workflowGetBySchema, query: { schemaCode } })).data;
29
+ const code = template && typeof template.workflowCode === "string" && template.workflowCode.length > 0 ? template.workflowCode : null;
30
+ return code;
31
+ }
32
+ function extractBizObjectPage(value) {
33
+ const root = value && typeof value === "object" ? value : {};
34
+ const candidate = root.bizObjectPage && typeof root.bizObjectPage === "object" ? root.bizObjectPage : root;
35
+ const content = Array.isArray(candidate.content) ? candidate.content.filter((row) => !!row && typeof row === "object") : [];
36
+ return {
37
+ total: typeof candidate.totalElements === "number" ? candidate.totalElements : 0,
38
+ totalPages: typeof candidate.totalPages === "number" ? candidate.totalPages : 0,
39
+ page: typeof candidate.page === "number" ? candidate.page : undefined,
40
+ size: typeof candidate.size === "number" ? candidate.size : undefined,
41
+ content,
42
+ };
43
+ }
44
+ /**
45
+ * Resolve the active form's sheetCode. Priority order: explicit caller arg
46
+ * (verbatim) > default-form sheetCode from bizsheet/get > schemaCode
47
+ * fallback. Returns the chosen sheetCode plus a provenance tag so callers
48
+ * can report which tier won.
49
+ */
50
+ async function resolveFormSheetCode(session, args) {
51
+ const explicit = typeof args.sheetCode === "string" && args.sheetCode.trim() ? args.sheetCode.trim() : "";
52
+ if (explicit)
53
+ return { sheetCode: explicit, resolvedFrom: "explicit" };
54
+ try {
55
+ const result = await wireCall(session, {
56
+ path: ENDPOINTS.sheetGet,
57
+ query: { schemaCode: args.schemaCode, sheetCode: args.schemaCode },
58
+ });
59
+ const data = result.data;
60
+ const candidate = data?.code ?? data?.sheetCode;
61
+ if (typeof candidate === "string" && candidate) {
62
+ return { sheetCode: candidate, resolvedFrom: "default-form" };
63
+ }
64
+ }
65
+ catch {
66
+ // fall through to schemaCode fallback
67
+ }
68
+ return { sheetCode: args.schemaCode, resolvedFrom: "fallback" };
69
+ }
70
+ /**
71
+ * Read the record status from a form/load payload. The live face nests it as
72
+ * `bizObject.data.sequenceStatus`; the flat fake form carries it top-level.
73
+ */
74
+ /**
75
+ * Read the workflow instance id from a form/load payload. The live face may
76
+ * carry it top-level, under `bizObject`, or under `bizObject.data` — the submit
77
+ * RESPONSE often omits it entirely (bench53: start/approval submits answered
78
+ * workflowInstanceId:null even though the instance existed), so the loaded row
79
+ * is the fallback the next-todo lookup depends on.
80
+ */
81
+ function extractWorkflowInstanceId(readback) {
82
+ if (!readback)
83
+ return null;
84
+ const candidates = [
85
+ readback.workflowInstanceId,
86
+ readback.bizObject?.workflowInstanceId,
87
+ readback.bizObject?.data?.workflowInstanceId,
88
+ ];
89
+ for (const candidate of candidates)
90
+ if (typeof candidate === "string" && candidate.length > 0)
91
+ return candidate;
92
+ return null;
93
+ }
94
+ function extractSequenceStatus(readback) {
95
+ if (!readback)
96
+ return null;
97
+ if (typeof readback.sequenceStatus === "string")
98
+ return readback.sequenceStatus;
99
+ const nested = readback.bizObject;
100
+ if (nested && typeof nested.sequenceStatus === "string")
101
+ return nested.sequenceStatus;
102
+ const nestedData = nested?.data;
103
+ if (nestedData && typeof nestedData.sequenceStatus === "string")
104
+ return nestedData.sequenceStatus;
105
+ return null;
106
+ }
107
+ /**
108
+ * Engine-owned record keys that must never be echoed back in a submit
109
+ * payload: persisting the loaded `sequenceStatus` pins the row to its
110
+ * pre-submit status (live evidence ticket 17 — the portal client only
111
+ * submits form fields, never the status column). The key list is the
112
+ * contract constant the fake's merge also consumes (ticket 20).
113
+ */
114
+ function stripEngineOwnedKeys(data) {
115
+ const stripped = {};
116
+ for (const [key, value] of Object.entries(data)) {
117
+ if (ENGINE_OWNED_KEYS.includes(key))
118
+ continue;
119
+ stripped[key] = value;
120
+ }
121
+ return stripped;
122
+ }
123
+ /**
124
+ * Normalize a form/load payload to the flat record contract the tool surface
125
+ * promises: business fields plus `id`/`sequenceStatus`. The live face returns
126
+ * a FormLoadVO nesting them under `bizObject.data`; the flat fake form
127
+ * carries them top-level. A null payload stays null (absence).
128
+ */
129
+ function normalizeLoadedRecord(payload) {
130
+ if (!payload)
131
+ return null;
132
+ const bizObject = payload.bizObject;
133
+ if (!bizObject || typeof bizObject !== "object")
134
+ return payload;
135
+ const record = bizObject;
136
+ const fields = record.data && typeof record.data === "object"
137
+ ? { ...record.data }
138
+ : {};
139
+ if (typeof record.id === "string")
140
+ fields.id = record.id;
141
+ if (typeof record.sequenceStatus === "string")
142
+ fields.sequenceStatus = record.sequenceStatus;
143
+ return fields;
144
+ }
145
+ /**
146
+ * Engine/system record keys that dominate a raw form/load payload but carry no
147
+ * business meaning for the caller: the `owner`/`creater`/`modifier` staff blobs
148
+ * and the owning-department objects. Each is a ~30-key object full of nulls
149
+ * (live 2026-09-25: a 23-key record was ~3 KB, of which these were ~2.5 KB).
150
+ * They stay reachable via `full:true` (raw record) and in the spilled artifact.
151
+ */
152
+ const RUNTIME_ENGINE_RECORD_KEYS = new Set([
153
+ "owner", "creater", "modifier", "createdDeptId", "ownerDeptId", "ownerDeptQueryCode",
154
+ "createdBy", "modifiedBy",
155
+ ]);
156
+ /**
157
+ * Default-compact a runtime record: drop only KNOWN engine noise — `*_` keys are
158
+ * flow-engine internals (`activityName_`, `participants_`, …) and the staff/dept
159
+ * blobs above. Every business value is kept verbatim, INCLUDING null/empty (a
160
+ * null business field is a real, readable state; dropping it would make it
161
+ * indistinguishable from an absent field). `full:true` returns the untouched
162
+ * record.
163
+ *
164
+ * Round 13: the filter now recurses. A child-table field comes back as an array
165
+ * of row objects, and each row carries its own owner/creater/dept blobs and
166
+ * `*_` internals — the first version only stripped the top level, so a record
167
+ * with a child table still shipped the noise one level down. The keys are
168
+ * platform-reserved (`SYSTEM_FIELD_CODES` / engine internals), so a business
169
+ * field cannot collide with them and recursion cannot eat authored data.
170
+ */
171
+ function compactRuntimeValue(value) {
172
+ if (Array.isArray(value))
173
+ return value.map(compactRuntimeValue);
174
+ if (value !== null && typeof value === "object")
175
+ return compactRuntimeRecord(value);
176
+ return value;
177
+ }
178
+ function compactRuntimeRecord(record) {
179
+ const compact = {};
180
+ for (const [key, value] of Object.entries(record)) {
181
+ if (key.endsWith("_"))
182
+ continue;
183
+ if (RUNTIME_ENGINE_RECORD_KEYS.has(key))
184
+ continue;
185
+ compact[key] = compactRuntimeValue(value);
186
+ }
187
+ return compact;
188
+ }
189
+ /**
190
+ * Round 3 (issue §D5): the write readback inlined the whole `authoritative`
191
+ * record (owner/creater objects included), ~4 KB a row. Default is now the
192
+ * compact projection (business fields + identity); `summary:true` keeps
193
+ * identity/status keys, `fields:[...]` keeps exactly the named keys, and
194
+ * `full:true` returns the untouched record.
195
+ */
196
+ function projectReadbackRecord(record, args) {
197
+ if (record === null || typeof record !== "object" || Array.isArray(record))
198
+ return record;
199
+ const source = record;
200
+ if (Array.isArray(args.fields) && args.fields.length > 0) {
201
+ const projected = {};
202
+ for (const field of args.fields)
203
+ if (field in source)
204
+ projected[field] = source[field];
205
+ return projected;
206
+ }
207
+ if (args.summary === true) {
208
+ const projected = {};
209
+ for (const key of ["id", "bizObjectId", "schemaCode", "name", "sequenceStatus"])
210
+ if (key in source)
211
+ projected[key] = source[key];
212
+ return projected;
213
+ }
214
+ if (args.full === true)
215
+ return record;
216
+ return compactRuntimeRecord(source);
217
+ }
218
+ /** Shared optional schema fields for the write readback projection. */
219
+ export const readbackProjectionArgs = {
220
+ summary: z.boolean().optional().describe("只回身份/状态字段,裁剪 readback.authoritative(默认关闭,读回为业务字段+身份的精简记录)"),
221
+ fields: z.array(z.string().min(1)).optional().describe("只保留 readback.authoritative 中列出的字段(比 summary 更精确)"),
222
+ full: z.boolean().optional().describe("返回未裁剪的原始记录(含 owner/creater 等系统字段与 *_ 引擎内部字段;默认已裁剪)"),
223
+ };
224
+ /**
225
+ * Authoritative existence probe for an existing business object. The platform
226
+ * form/load face returns either a null payload or a business error
227
+ * (304001 业务对象不存在) when the object is absent; both are absence, not a
228
+ * read failure. `data.update` uses this to refuse an absent target instead of
229
+ * letting runtime/form/save silently insert (BizObjectFacadeImpl falls back to
230
+ * createBizObject when the id is not found).
231
+ */
232
+ async function loadExistingBizObject(session, schemaCode, sheetCode, objectId) {
233
+ try {
234
+ const result = await wireCall(session, {
235
+ path: ENDPOINTS.runtimeFormLoad,
236
+ query: { schemaCode, sheetCode, objectId },
237
+ });
238
+ const value = result.data;
239
+ if (value == null || value === false)
240
+ return null;
241
+ return typeof value === "object" ? value : {};
242
+ }
243
+ catch (error) {
244
+ if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
245
+ return null;
246
+ throw error;
247
+ }
248
+ }
249
+ /**
250
+ * Portal-required fields the published form declares (`control.options.required`
251
+ * or `requiredFormula`). The portal enforces these client-side; the REST
252
+ * runtime/form/submit path does NOT, so a submit with a required field missing
253
+ * returns ok/PROCESSING anyway (dogfood R3: `amount:null` still advanced the
254
+ * workflow). Read the PUBLISHED form controls and cross-check the submitted
255
+ * formData, then report the discrepancy instead of letting the caller believe a
256
+ * portal-invalid submit succeeded.
257
+ */
258
+ async function portalRequiredViolations(session, schemaCode, sheetCode, formData) {
259
+ const sheet = ((await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode, sheetCode } })).data ?? {});
260
+ const face = typeof sheet.publishedAttributesJson === "string" ? sheet.publishedAttributesJson : typeof sheet.draftAttributesJson === "string" ? sheet.draftAttributesJson : undefined;
261
+ if (!face)
262
+ return { requiredFields: [], missingRequired: [] };
263
+ let parsed;
264
+ try {
265
+ parsed = JSON.parse(face);
266
+ }
267
+ catch {
268
+ return { requiredFields: [], missingRequired: [] };
269
+ }
270
+ const requiredFields = Object.entries(parsed)
271
+ .filter(([, control]) => control?.options?.required === true || typeof control?.options?.requiredFormula === "string")
272
+ .map(([code]) => code);
273
+ const missingRequired = requiredFields.filter((code) => {
274
+ const value = formData[code];
275
+ return value === null || value === undefined || (typeof value === "string" && value.trim() === "");
276
+ });
277
+ return { requiredFields, missingRequired };
278
+ }
279
+ /**
280
+ * The platform dispatches each CHILD_TABLE(8) row on the form-save path by its
281
+ * `rowStatus` (BizObjectUpdateModel.convertBizObjectOpItem): "Added" → insert,
282
+ * "Modified" → update, "Deleted" → delete, and **any other/missing value →
283
+ * immutable (silently ignored)**. The portal client stamps it (the compiled
284
+ * admin bundle sets `rowStatus:"Modified"` on edited rows and `"Added"` on new
285
+ * ones); a hand-built payload that omits it loses every child edit while the
286
+ * write still answers errcode 0 (live 2026-10-08: data.update of a child row
287
+ * without rowStatus left process_name/plan fields unchanged). Stamp the obvious
288
+ * intent so OMC never reports a successful child write that did not land: a row
289
+ * carrying its own `id` is "Modified", an id-less row is "Added"; an explicit
290
+ * rowStatus is preserved verbatim.
291
+ */
292
+ async function stampChildRowStatuses(session, schemaCode, formData) {
293
+ const childCodes = new Set();
294
+ try {
295
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
296
+ for (const row of rows) {
297
+ if (Number(row.propertyType ?? row.type) === CHILD_TABLE_PROPERTY_TYPE)
298
+ childCodes.add(String(row.code ?? row.propertyCode ?? ""));
299
+ }
300
+ }
301
+ catch {
302
+ return formData;
303
+ }
304
+ if (childCodes.size === 0)
305
+ return formData;
306
+ let changed = false;
307
+ const next = { ...formData };
308
+ for (const code of childCodes) {
309
+ const value = formData[code];
310
+ if (!Array.isArray(value))
311
+ continue;
312
+ next[code] = value.map((entry) => {
313
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
314
+ return entry;
315
+ const row = entry;
316
+ if (typeof row.rowStatus === "string" && row.rowStatus.trim())
317
+ return row;
318
+ changed = true;
319
+ const hasId = typeof row.id === "string" && row.id.trim().length > 0;
320
+ return { ...row, rowStatus: hasId ? "Modified" : "Added" };
321
+ });
322
+ }
323
+ return changed ? next : formData;
324
+ }
325
+ /** Runtime business-object data keyed by field code. */
326
+ export function registerRuntimeDataLane(tools) {
327
+ tools.push(defineTool({
328
+ name: "data.list",
329
+ layer: "low",
330
+ kind: "read",
331
+ lane: RUNTIME_LANE,
332
+ scope: RUNTIME_TOOLS["data.list"].scope,
333
+ description: RUNTIME_TOOLS["data.list"].description,
334
+ inputSchema: {
335
+ environment: environmentArg,
336
+ schemaCode: z.string().min(1),
337
+ queryCode: z.string().min(1).optional().describe("query view code; defaults to schemaCode (the default list view)"),
338
+ // `filter` may be a SINGLE clause object `{op,field,value}` or an ARRAY
339
+ // of clauses (a natural mistake that the old schema rejected with a bare
340
+ // `Expected object, received array`). An array is the caller's AND-list,
341
+ // so accept it and treat it as `filters` rather than costing a retry
342
+ // (dogfood R1 round17).
343
+ filter: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).optional().describe('one clause { op, field, value? } (or an array of clauses = AND); op is one of eq, neq, gt, lt, gte, lte, like, notlike, in, notin, between, notbetween, isnull, isnotnull'),
344
+ filters: z.array(z.record(z.string(), z.unknown())).optional().describe("clauses combined with AND; a malformed clause is refused with blocker invalid-filter-DSL"),
345
+ filterGroup: z.record(z.string(), z.unknown()).optional().describe('nested group { op:"and"|"or", clauses:[clause|group] }'),
346
+ rawCondition: z.unknown().optional(),
347
+ displayFields: z.array(z.string().min(1)).optional(),
348
+ columns: z.array(z.string().min(1)).optional(),
349
+ orders: z.array(z.object({ field: z.string().min(1), dir: z.enum(["ASC", "DESC"]) })).optional(),
350
+ mobile: z.boolean().optional(),
351
+ showTotal: z.boolean().default(true),
352
+ page: z.number().int().min(0).default(0),
353
+ size: z.number().int().min(1).max(200).default(10),
354
+ full: z.boolean().optional().describe("内联完整列表结果;默认大结果只返回计数与 detailFile"),
355
+ capability: capabilityArg,
356
+ },
357
+ handler: async (ctx, args, self) => {
358
+ let condition;
359
+ let fromDsl = false;
360
+ try {
361
+ // An array `filter` is the caller's AND-list; fold it into `filters`.
362
+ const asFilters = args.filters ?? (Array.isArray(args.filter) ? args.filter : undefined);
363
+ const asFilter = Array.isArray(args.filter) ? undefined : args.filter;
364
+ condition = toQueryCondition({
365
+ rawCondition: args.rawCondition,
366
+ ...(asFilter !== undefined ? { filter: asFilter } : {}),
367
+ ...(asFilters !== undefined ? { filters: asFilters } : {}),
368
+ ...(args.filterGroup !== undefined ? { filterGroup: args.filterGroup } : {}),
369
+ });
370
+ fromDsl = condition !== undefined && args.rawCondition === undefined;
371
+ }
372
+ catch (error) {
373
+ if (error instanceof FilterDslError)
374
+ return {
375
+ status: "refused",
376
+ blocker: "invalid-filter-DSL",
377
+ reason: error.code,
378
+ message: `${error.message};filter 的最小合法形状:{"filter":{"op":"eq","field":"<字段code>","value":"<值>"}}(op 取 eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull);多条件用 filters:[clause,…](AND)或 filterGroup:{op:"and"|"or",clauses:[…]}。可运行示例:describe data.list --example list-filter-equality`,
379
+ minimalValidFilter: { filter: { op: "eq", field: "<fieldCode>", value: "<value>" } },
380
+ acceptedOps: ["eq", "neq", "gt", "lt", "gte", "lte", "like", "notlike", "in", "notin", "between", "notbetween", "isnull", "isnotnull"],
381
+ };
382
+ throw error;
383
+ }
384
+ return governedRead(ctx, self, args, async (session) => {
385
+ // Published field metadata is the sole source for filter propertyType
386
+ // injection AND for the projectionHint below. Fetch at most once per
387
+ // call: the DSL path needs it up front; the hint path asks only after
388
+ // an explicit projection came back all-null, so a normal read that
389
+ // yields values performs zero metadata requests.
390
+ let publishedTypes = null;
391
+ const publishedTypeByCode = async () => {
392
+ if (publishedTypes)
393
+ return publishedTypes;
394
+ const meta = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
395
+ publishedTypes = new Map(meta.map((row) => [String(row.code ?? row.propertyCode), Number(row.propertyType ?? row.type)]));
396
+ return publishedTypes;
397
+ };
398
+ if (fromDsl) {
399
+ const typeByCode = await publishedTypeByCode();
400
+ const injected = injectPropertyTypes(condition, (code) => {
401
+ const type = typeByCode.get(code);
402
+ return Number.isInteger(type) ? type : undefined;
403
+ });
404
+ if (!injected.ok)
405
+ return {
406
+ status: "refused",
407
+ blocker: "unknown-filter-field",
408
+ missing: injected.missing,
409
+ message: `filter fields not found in published field metadata: ${injected.missing.join(", ")}; the runtime query requires each leaf's propertyType from field metadata (no guessing); system/foreign fields may use rawCondition`,
410
+ };
411
+ condition = injected.condition;
412
+ }
413
+ const requestedFields = args.displayFields ?? args.columns;
414
+ const fields = requestedFields && requestedFields.length > 0 ? requestedFields : undefined;
415
+ const body = {
416
+ schemaCode: args.schemaCode,
417
+ queryCode: args.queryCode ?? args.schemaCode,
418
+ filters: [],
419
+ mobile: args.mobile === true,
420
+ page: args.page ?? 0,
421
+ size: args.size ?? 10,
422
+ queryVersion: 1,
423
+ showTotal: args.showTotal !== false,
424
+ ...(condition !== undefined ? { queryCondition: condition } : {}),
425
+ };
426
+ // An empty model answers with a business error (301005) on this face;
427
+ // that is "no data yet", not a query failure.
428
+ let result;
429
+ try {
430
+ result = await wireCall(session, { path: ENDPOINTS.runtimeQueryList, method: "POST", body });
431
+ }
432
+ catch (error) {
433
+ if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode) {
434
+ return { schemaCode: args.schemaCode, total: 0, totalPages: 0, page: args.page ?? 0, size: args.size ?? 10, items: [] };
435
+ }
436
+ throw error;
437
+ }
438
+ const page = extractBizObjectPage(result.data);
439
+ const items = page.content.map((row) => {
440
+ const rawData = row.data && typeof row.data === "object" ? row.data : {};
441
+ // Round 10: default-compact each row with the SAME known-noise filter
442
+ // data.load uses (`*_` flow internals + owner/creater/dept blobs), so a
443
+ // list read no longer ships engine fields the caller then filters out.
444
+ // Explicit `fields` still projects exactly what was asked, and
445
+ // `full:true` keeps the untouched row.
446
+ const data = args.full === true ? rawData : compactRuntimeRecord(rawData);
447
+ const selected = fields ?? Object.keys(data).filter((field) => !["id", "bizObjectId", "schemaCode", "sequenceStatus", "createdTime", "updatedTime", "creator", "updater"].includes(field));
448
+ return { id: row.bizObjectId ?? row.id ?? data.id, fields: Object.fromEntries(selected.map((field) => [field, data[field] ?? null])) };
449
+ });
450
+ const readback = {
451
+ schemaCode: args.schemaCode,
452
+ ...(args.queryCode !== undefined ? { queryCode: args.queryCode } : {}),
453
+ total: args.showTotal === false ? null : page.total,
454
+ totalPages: page.totalPages,
455
+ page: page.page ?? args.page ?? 0,
456
+ size: page.size ?? args.size ?? 20,
457
+ items,
458
+ };
459
+ // A model's unconfigured default list view projects business columns as
460
+ // null even though the stored values exist (ticket 07). Only when the
461
+ // caller explicitly projected columns, EVERY returned row yielded null
462
+ // for EVERY selected column, and the published metadata confirms those
463
+ // are real model fields, surface the recovery hint. `items` is never
464
+ // reshaped — the hint is a sibling readback field.
465
+ if (fields &&
466
+ fields.length > 0 &&
467
+ items.length > 0 &&
468
+ items.every((item) => fields.every((field) => item.fields[field] === null))) {
469
+ const typeByCode = await publishedTypeByCode();
470
+ if (fields.every((field) => typeByCode.has(field)))
471
+ readback.projectionHint = RUNTIME_DATA_WIRE.projectionHint;
472
+ }
473
+ return readback;
474
+ }, { full: args.full === true, target: `data.list:${JSON.stringify({ schemaCode: args.schemaCode, queryCode: args.queryCode ?? args.schemaCode, filter: args.filter, filters: args.filters, filterGroup: args.filterGroup, rawCondition: args.rawCondition, displayFields: args.displayFields, columns: args.columns, page: args.page ?? 0, size: args.size ?? 10 })}` });
475
+ },
476
+ }));
477
+ tools.push(defineTool({
478
+ name: "data.load",
479
+ layer: "low",
480
+ kind: "read",
481
+ lane: RUNTIME_LANE,
482
+ scope: RUNTIME_TOOLS["data.load"].scope,
483
+ description: RUNTIME_TOOLS["data.load"].description,
484
+ inputSchema: {
485
+ environment: environmentArg,
486
+ schemaCode: z.string(),
487
+ bizObjectId: z.string({ required_error: "bizObjectId 必填(业务对象 id;参数名是 bizObjectId,不是 id。从 data.submit 读回的 objectId/sequenceNo 或 data.list 的 row.bizObjectId 取)" }),
488
+ full: z.boolean().optional().describe("返回未裁剪的完整业务对象(含 owner/creater 等系统字段;默认返回业务字段+身份的精简记录)"),
489
+ capability: capabilityArg,
490
+ },
491
+ handler: async (ctx, args, self) => {
492
+ const includeSchema = !ctx.schemaCache.has(args.schemaCode);
493
+ return governedRead(ctx, self, args, async (session) => {
494
+ let result;
495
+ try {
496
+ result = await wireCall(session, {
497
+ path: ENDPOINTS.runtimeFormLoad,
498
+ query: { schemaCode: args.schemaCode, sheetCode: args.schemaCode, objectId: args.bizObjectId },
499
+ });
500
+ }
501
+ catch (error) {
502
+ // An absent (e.g. deleted) object answers the business error 304001
503
+ // 业务对象不存在. That is AUTHORITATIVE ABSENCE, not a governance
504
+ // refusal — surfacing it as policy-refused misleads a cleanup check
505
+ // (bench18 verified a delete by data.load and got policy-refused).
506
+ if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
507
+ return { schemaCode: args.schemaCode, bizObjectId: args.bizObjectId, exists: false, data: null, note: "业务对象不存在(304001 = 缺席信号,非失败)" };
508
+ throw error;
509
+ }
510
+ // Flat record contract: live FormLoadVO business fields normalize
511
+ // out of `bizObject.data` (ticket 17).
512
+ const normalized = normalizeLoadedRecord(result.data && typeof result.data === "object" ? result.data : null);
513
+ let data = normalized ?? result.data;
514
+ if (data && typeof data === "object" && !Array.isArray(data)) {
515
+ if (!includeSchema)
516
+ delete data.bizSchema;
517
+ if (args.full !== true)
518
+ data = compactRuntimeRecord(data);
519
+ }
520
+ if (includeSchema)
521
+ ctx.schemaCache.add(args.schemaCode);
522
+ return { schemaCode: args.schemaCode, bizObjectId: args.bizObjectId, exists: data != null, ...(includeSchema ? {} : { schemaCached: true }), data };
523
+ }, { full: args.full === true, target: `data.load:${args.schemaCode}:${args.bizObjectId}` });
524
+ },
525
+ }));
526
+ writeTool(tools, "data.save", RUNTIME_LANE, RUNTIME_TOOLS["data.save"].scope, RUNTIME_TOOLS["data.save"].description, {
527
+ schemaCode: z.string().min(1),
528
+ sheetCode: z.string().optional(),
529
+ formData: runtimeFormDataArg,
530
+ workflowCode: z.string().optional().describe("portal draft-save shape: create the row AND a DRAFT workflow instance + start workItem (finishStart=FALSE); submit later via data.submit with the returned workItemId. formData must NOT carry an id — in-place workflow start has no REST surface in 8.6.26"),
531
+ ...readbackProjectionArgs,
532
+ }, async (session, args) => {
533
+ const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
534
+ const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
535
+ if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
536
+ // Portal draft-save shape. The row id travels in data.id (the portal
537
+ // form SDK receives a pre-generated id from form/load and echoes it);
538
+ // BizObjectModel.getId() reads data["id"], so a client-chosen id is
539
+ // echoed back as FormSaveVO.objectId. An id that already exists in
540
+ // the store would hit the create-path INSERT duplicate (40000), so an
541
+ // existing id is refused up front.
542
+ const suppliedId = typeof formData.id === "string" ? formData.id.trim() : "";
543
+ if (suppliedId) {
544
+ const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, suppliedId);
545
+ if (existing) {
546
+ throw new ToolRefusalError({
547
+ status: "refused",
548
+ blocker: "save-workflow-draft-with-id-unsupported",
549
+ schemaCode: args.schemaCode,
550
+ bizObjectId: suppliedId,
551
+ workflowCode: args.workflowCode,
552
+ message: "workflow draft-save always INSERTs in 8.6.26 (no REST in-place start for an existing row); use a fresh id or no id, or submit an existing workflow draft via data.submit with its workItemId",
553
+ });
554
+ }
555
+ }
556
+ const objectId = suppliedId || randomUUID().replaceAll("-", "");
557
+ // isStartWf branch runs verifyReplay; the instance is DRAFT only when
558
+ // data carries sequenceStatus DRAFT (an empty default builds PROCESSING).
559
+ const replayToken = await getReplayToken(session);
560
+ const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
561
+ path: ENDPOINTS.runtimeFormSave,
562
+ method: "POST",
563
+ body: {
564
+ workflowCode: args.workflowCode,
565
+ ...(replayToken ? { replayToken } : {}),
566
+ bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: objectId, sequenceStatus: "DRAFT" } },
567
+ },
568
+ }), formData);
569
+ const vo = (performed.data ?? {});
570
+ const returnedId = typeof vo.objectId === "string" && vo.objectId ? vo.objectId : typeof vo.bizObjectId === "string" && vo.bizObjectId ? vo.bizObjectId : objectId;
571
+ const workflowInstanceId = typeof vo.workflowInstanceId === "string" ? vo.workflowInstanceId : null;
572
+ // saveWorkflow resolves the start workItem via getWorkItem INSIDE the
573
+ // still-uncommitted request transaction (autoCommit:false), so the live
574
+ // FormSaveVO usually omits workItem. The committed row is visible to a
575
+ // follow-up search_workitems read; resolve the pending start todo by
576
+ // exact instanceId (the server filter is a NOT-equal exclusion).
577
+ let workItem = vo.workItem;
578
+ let workItemId = workItem && typeof workItem.id === "string" ? workItem.id : null;
579
+ let workItemSource = workItemId ? "form-save" : null;
580
+ if (!workItemId && workflowInstanceId) {
581
+ // search_workitems via the shared workflow seam (ticket 21): exact
582
+ // instanceId match is client-side (the server filter is a NOT-equal
583
+ // exclusion); the seam's default size 200 is the unified scan width.
584
+ const found = await searchWorkItems(session, {
585
+ workflowCode: args.workflowCode,
586
+ instanceId: workflowInstanceId,
587
+ });
588
+ const matchId = found.items.length > 0 ? wireString(found.items[0].id) : null;
589
+ if (matchId) {
590
+ workItemId = matchId;
591
+ workItemSource = "workitem-search";
592
+ }
593
+ }
594
+ let readbackData = null;
595
+ let sequenceStatus = null;
596
+ readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: returnedId } })).data;
597
+ sequenceStatus = extractSequenceStatus((readbackData ?? {}));
598
+ return {
599
+ performed: { errcode: performed.errcode, bizObjectId: returnedId, workflowInstanceId, workItemId, workItemSource, sheetCode, resolvedFrom, formType: "save-draft-workflow" },
600
+ readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
601
+ };
602
+ }
603
+ const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
604
+ path: ENDPOINTS.runtimeFormSave,
605
+ method: "POST",
606
+ body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
607
+ }), formData);
608
+ const bizObjectId = performed.data?.bizObjectId ?? performed.data?.objectId ?? performed.data;
609
+ let readbackData = null;
610
+ if (typeof bizObjectId === "string" && bizObjectId) {
611
+ readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } })).data;
612
+ }
613
+ return {
614
+ performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom },
615
+ readback: {
616
+ authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args),
617
+ // Ticket 111: a plain save lands as sequenceStatus DRAFT, and DRAFT
618
+ // rows SKIP the Create/Update rule dispatch entirely
619
+ // (DomainServiceImpl.java:240-248 buckets drafts before the rule
620
+ // branch) — business rules only run once data.submit advances the
621
+ // row. Saying so on every plain-save readback prevents the
622
+ // "rule didn't fire" misdiagnosis.
623
+ draftRuleHint: "草稿态(DRAFT)保存不触发业务规则——DRAFT 行跳过 Create/Update 分发(DomainServiceImpl.java:240-248);data.submit 提交(DRAFT→COMPLETED)后才触发 Create 规则",
624
+ },
625
+ };
626
+ });
627
+ writeTool(tools, "data.update", RUNTIME_LANE, RUNTIME_TOOLS["data.update"].scope, RUNTIME_TOOLS["data.update"].description, {
628
+ schemaCode: z.string().min(1),
629
+ sheetCode: z.string().optional(),
630
+ bizObjectId: z.string().min(1).describe("id of the EXISTING record to edit; the tool refuses when it is absent (never silently inserts)"),
631
+ formData: runtimeFormDataArg.describe("fields to change, keyed by field code; the platform merges them onto the stored record, so omitted fields keep their value (patch semantics)"),
632
+ ...readbackProjectionArgs,
633
+ }, async (session, args) => {
634
+ const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
635
+ const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, args.bizObjectId);
636
+ if (!existing) {
637
+ // The platform's save-with-id path silently inserts when the id is
638
+ // unknown (BizObjectFacadeImpl.createBizObject). Refuse instead of
639
+ // turning an edit into an accidental create.
640
+ throw new ToolRefusalError({
641
+ status: "refused",
642
+ blocker: "update-target-absent",
643
+ errcode: RUNTIME_DATA_WIRE.absenceErrcode,
644
+ schemaCode: args.schemaCode,
645
+ bizObjectId: args.bizObjectId,
646
+ sheetCode,
647
+ message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
648
+ });
649
+ }
650
+ const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
651
+ const performed = await wireCall(session, {
652
+ path: ENDPOINTS.runtimeFormSave,
653
+ method: "POST",
654
+ // Live-proven shape: BizObjectModel.getId() reads data["id"], so the
655
+ // target id MUST travel inside `data`; a top-level bizObject.id is
656
+ // ignored and the platform silently creates a new row.
657
+ body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: args.bizObjectId } } },
658
+ });
659
+ const performedData = (performed.data ?? {});
660
+ const returnedId = typeof performedData.objectId === "string" ? performedData.objectId : typeof performedData.bizObjectId === "string" ? performedData.bizObjectId : args.bizObjectId;
661
+ const readbackData = await loadExistingBizObject(session, args.schemaCode, sheetCode, returnedId);
662
+ const sequenceStatus = extractSequenceStatus(readbackData);
663
+ return {
664
+ performed: { errcode: performed.errcode, bizObjectId: returnedId, sheetCode, resolvedFrom },
665
+ // Flat record contract (ticket 17): live FormLoadVO normalizes to the
666
+ // fake's flat field shape so `authoritative.<fieldCode>` reads work on
667
+ // both faces.
668
+ readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord(readbackData), args), sequenceStatus },
669
+ };
670
+ });
671
+ /**
672
+ * Round 16b (dogfood R1-R3): `data.delete`'s `absent:true` only proves THAT id is
673
+ * gone, not that the model is clean — a submit can leave a separate draft row
674
+ * behind. After a delete, read the model's residue so "cleaned up" is a
675
+ * model-level fact (`total:0`). Best-effort: a query failure never fails the
676
+ * delete, but it IS surfaced as `modelResidueError`.
677
+ */
678
+ async function modelResidue(session, schemaCode, sheetCode) {
679
+ try {
680
+ const result = await wireCall(session, {
681
+ path: ENDPOINTS.runtimeQueryList,
682
+ method: "POST",
683
+ body: { schemaCode, queryCode: schemaCode, filters: [], mobile: false, page: 0, size: 1, queryVersion: 1, showTotal: true },
684
+ });
685
+ const page = extractBizObjectPage(result.data);
686
+ return { modelResidue: { total: page.total, clean: page.total === 0, sheetCode } };
687
+ }
688
+ catch (error) {
689
+ if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode)
690
+ return { modelResidue: { total: 0, clean: true, sheetCode } };
691
+ return { modelResidue: { total: null, clean: null, sheetCode, modelResidueError: error instanceof Error ? error.message : String(error) } };
692
+ }
693
+ }
694
+ writeTool(tools, "data.delete", RUNTIME_LANE, RUNTIME_TOOLS["data.delete"].scope, RUNTIME_TOOLS["data.delete"].description, { schemaCode: z.string().min(1), sheetCode: z.string().optional(), bizObjectId: z.string().min(1).optional(), bizObjectIds: z.array(z.string().min(1)).optional().describe("批量删除多个业务对象 id(等价于逐个 data.delete,但只花一次往返;返回 deleted 列表)") }, async (session, args) => {
695
+ const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
696
+ const ids = args.bizObjectIds && args.bizObjectIds.length > 0 ? args.bizObjectIds : args.bizObjectId ? [args.bizObjectId] : [];
697
+ if (ids.length === 0)
698
+ throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "data.delete", message: "需要 bizObjectId(单个)或 bizObjectIds(数组,批量)" });
699
+ const deleteOne = async (bizObjectId) => {
700
+ // Delete is destructive: probe the authoritative object first so an
701
+ // absent id is an idempotent no-op, never a blind runtime delete.
702
+ const before = await loadExistingBizObject(session, args.schemaCode, sheetCode, bizObjectId);
703
+ if (!before)
704
+ return { bizObjectId, skipped: "already-absent", absent: true };
705
+ const performed = await wireCall(session, {
706
+ path: ENDPOINTS.runtimeFormDelete,
707
+ method: "POST",
708
+ query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId },
709
+ });
710
+ // The absence readback: a missing object surfaces either as an empty
711
+ // payload or as a business error (304001 业务对象不存在) from form/load.
712
+ // Both are authoritative absence, not a delete failure.
713
+ let absent = false;
714
+ try {
715
+ const readback = await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } });
716
+ const value = readback.data;
717
+ absent = value == null || value === false;
718
+ }
719
+ catch (error) {
720
+ if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
721
+ absent = true;
722
+ else
723
+ throw error;
724
+ }
725
+ return { bizObjectId, errcode: performed.errcode, absent };
726
+ };
727
+ if (ids.length === 1) {
728
+ const one = await deleteOne(ids[0]);
729
+ if (one.skipped === "already-absent")
730
+ return { performed: { skipped: "already-absent", sheetCode, resolvedFrom }, readback: { absent: true, recoveryHint: "id 不存在(already-absent≠删除成功):真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏,shell 空值兜底出来的 id 同样删不掉任何东西。若 modelResidue.total>0,用 items[].id 重删。", ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
731
+ return { performed: { errcode: one.errcode, sheetCode, resolvedFrom }, readback: { absent: one.absent, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
732
+ }
733
+ const deleted = [];
734
+ for (const id of ids)
735
+ deleted.push(await deleteOne(id));
736
+ // `deleted`/`count` ride `readback` — the write envelope surfaces only
737
+ // performed/readback inline (extra top-level keys are artifact-only).
738
+ return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
739
+ });
740
+ writeTool(tools, "data.submit", RUNTIME_LANE, RUNTIME_TOOLS["data.submit"].scope, RUNTIME_TOOLS["data.submit"].description, {
741
+ schemaCode: z.string().min(1),
742
+ sheetCode: z.string().optional(),
743
+ formData: runtimeFormDataArg,
744
+ workflowCode: z.string().optional(),
745
+ workflowInstanceId: z.string().optional(),
746
+ workItemId: z.string().optional(),
747
+ bizObjectId: z.string().optional().describe("existing record id; REQUIRED for workItem approval submits (formType=1 + workItemId) — the submit's bizObject must reference the in-flight record or the engine NPEs (DataChangeHandlerImpl.changeBizObject)"),
748
+ agree: z.boolean().optional(),
749
+ approval: z.record(z.string(), z.unknown()).optional(),
750
+ runMode: z.string().optional(),
751
+ isSimulative: z.boolean().optional(),
752
+ ...readbackProjectionArgs,
753
+ }, async (session, args) => {
754
+ const hasWorkItem = typeof args.workItemId === "string" && args.workItemId.trim().length > 0;
755
+ const hasInstance = typeof args.workflowInstanceId === "string" && args.workflowInstanceId.trim().length > 0;
756
+ const targetObjectId = typeof args.bizObjectId === "string" && args.bizObjectId.trim() ? args.bizObjectId.trim() : null;
757
+ if ((hasWorkItem || hasInstance) && !targetObjectId) {
758
+ throw new ToolRefusalError({
759
+ status: "refused",
760
+ blocker: "submit-biz-object-id-required",
761
+ schemaCode: args.schemaCode,
762
+ workItemId: args.workItemId,
763
+ workflowInstanceId: args.workflowInstanceId,
764
+ message: "workItemId or workflowInstanceId submit requires bizObjectId; refusing before runtime/form/submit",
765
+ });
766
+ }
767
+ const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
768
+ const formData = await stampChildRowStatuses(session, args.schemaCode, args.formData);
769
+ // Portal required-field semantics: the REST submit does NOT enforce them,
770
+ // so a missing required value must be REPORTED, not silently accepted as a
771
+ // portal-valid submit (dogfood R3). Best-effort read; a failure never
772
+ // blocks the submit.
773
+ const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData).catch(() => ({ requiredFields: [], missingRequired: [] }));
774
+ // Directive: a workflow-backed model's submit must START the workflow, not
775
+ // merely save a sheet row. When the caller names no workflow params, detect
776
+ // the model's workflow and start it (formType=1 + workflowCode); a model
777
+ // with no workflow stays a direct sheet submit (formType=2 -> COMPLETED).
778
+ let explicitWorkflowCode = typeof args.workflowCode === "string" && args.workflowCode.trim().length > 0 ? args.workflowCode.trim() : "";
779
+ let autoStarted = false;
780
+ if (!explicitWorkflowCode && !hasWorkItem && !hasInstance) {
781
+ const resolved = await resolvePublishedWorkflowCode(session, args.schemaCode);
782
+ if (resolved) {
783
+ explicitWorkflowCode = resolved;
784
+ autoStarted = true;
785
+ }
786
+ }
787
+ const hasWorkflowCode = explicitWorkflowCode.length > 0;
788
+ const hasWorkflow = hasWorkflowCode || hasWorkItem || hasInstance;
789
+ const formType = hasWorkflow ? "1" : "2";
790
+ const payload = {
791
+ formType,
792
+ bizObject: {
793
+ schemaCode: args.schemaCode,
794
+ sheetCode,
795
+ data: args.formData,
796
+ },
797
+ agree: args.agree !== false,
798
+ };
799
+ let draftObjectId = null;
800
+ if (targetObjectId && hasWorkflowCode && !hasWorkItem && !hasInstance) {
801
+ throw new ToolRefusalError({
802
+ status: "refused",
803
+ blocker: "submit-existing-workflow-unsupported",
804
+ schemaCode: args.schemaCode,
805
+ bizObjectId: targetObjectId,
806
+ workflowCode: explicitWorkflowCode,
807
+ message: "8.6.26 has no REST in-place workflow start for an existing no-instance row (submitWorkflowByWorkflowCode -> createAndGetBizObject INSERTs -> 40000 duplicate primary key; the load+update starter is facade-only). Supported shape: create workflow drafts via data.save with workflowCode (returns same-id row + DRAFT instance + start workItemId), then data.submit with that workItemId + workflowInstanceId + bizObjectId",
808
+ });
809
+ }
810
+ let preSubmitExisting = null;
811
+ if (targetObjectId) {
812
+ const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, targetObjectId);
813
+ preSubmitExisting = existing;
814
+ if (!existing) {
815
+ throw new ToolRefusalError({
816
+ status: "refused",
817
+ blocker: "submit-target-absent",
818
+ errcode: RUNTIME_DATA_WIRE.absenceErrcode,
819
+ schemaCode: args.schemaCode,
820
+ bizObjectId: targetObjectId,
821
+ sheetCode,
822
+ message: "no live business object with this id; refusing to submit (the platform would otherwise create a new row)",
823
+ });
824
+ }
825
+ // Direct-submit chain (ticket 18): form/submit persists bizObject.data
826
+ // itself — formType=2 submitBizSheet -> saveBizObject (sets COMPLETED,
827
+ // merges via updateBizObject.mergeObject); formType=1 workItem ->
828
+ // SubmitDataChangeHandlerImpl.updateBizObject — so the historical
829
+ // pre-submit form/save + form/load round-trip is redundant wire. The
830
+ // submit bizObject is synthesized from the authoritative pre-check
831
+ // load overlaid with the caller's formData.
832
+ const flat = normalizeLoadedRecord(existing) ?? {};
833
+ payload.bizObject = {
834
+ id: targetObjectId,
835
+ schemaCode: args.schemaCode,
836
+ sheetCode,
837
+ ownerId: typeof flat.ownerId === "string" ? flat.ownerId : undefined,
838
+ // Engine-owned keys must never be echoed back: the loaded row's
839
+ // `sequenceStatus: DRAFT` would be persisted verbatim and pin the
840
+ // row to DRAFT across submits (the portal client never resubmits
841
+ // the status column; live TID 10e6bc65 evidence, ticket 17).
842
+ data: stripEngineOwnedKeys({ ...flat, ...formData }),
843
+ };
844
+ if (formType === "2")
845
+ payload.actionCode = "submit";
846
+ draftObjectId = targetObjectId;
847
+ }
848
+ else if (formType === "2") {
849
+ const saved = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
850
+ path: ENDPOINTS.runtimeFormSave,
851
+ method: "POST",
852
+ body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
853
+ }));
854
+ const savedData = (saved.data ?? {});
855
+ draftObjectId = typeof savedData.bizObjectId === "string" ? savedData.bizObjectId : typeof savedData.objectId === "string" ? savedData.objectId : null;
856
+ if (!draftObjectId)
857
+ throw new WireError("business", "submit chain: draft save returned no bizObjectId", 304001);
858
+ // No post-save form/load (ticket 18): the submit persists
859
+ // bizObject.data itself and updateBizObject merges patch-style onto
860
+ // the stored row, and initSubmitBizObject.setOwner re-reads the
861
+ // stored row's owner server-side — so formData + the saved id is the
862
+ // complete submit payload.
863
+ payload.bizObject = {
864
+ id: draftObjectId,
865
+ schemaCode: args.schemaCode,
866
+ sheetCode,
867
+ data: stripEngineOwnedKeys(formData),
868
+ };
869
+ payload.actionCode = "submit";
870
+ }
871
+ let replayToken;
872
+ if (hasWorkflowCode && !hasWorkItem && !hasInstance) {
873
+ // Starting a workflow (formType=1 + workflowCode, no workItem) requires
874
+ // a replayToken pre-registered in Redis via GET /runtime/form/getReplayToken
875
+ // (AbstractFormRuntimeController.verifyReplay -> SYS_REPLAY_ERROR 100005).
876
+ replayToken = await getReplayToken(session);
877
+ }
878
+ if (hasWorkflowCode)
879
+ payload.workflowCode = explicitWorkflowCode;
880
+ if (replayToken)
881
+ payload.replayToken = replayToken;
882
+ if (typeof args.workflowInstanceId === "string")
883
+ payload.workflowInstanceId = args.workflowInstanceId;
884
+ if (typeof args.workItemId === "string")
885
+ payload.workItemId = args.workItemId;
886
+ if (args.approval && typeof args.approval === "object")
887
+ payload.approval = args.approval;
888
+ if (typeof args.runMode === "string")
889
+ payload.runMode = args.runMode;
890
+ if (typeof args.isSimulative === "boolean")
891
+ payload.isSimulative = args.isSimulative;
892
+ const performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
893
+ path: ENDPOINTS.runtimeFormSubmit,
894
+ method: "POST",
895
+ body: payload,
896
+ }));
897
+ const performedData = (performed.data ?? {});
898
+ let readback = { formType };
899
+ if (formType === "2") {
900
+ const objectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
901
+ if (!objectId) {
902
+ // Source contract: submitBizSheet ALWAYS returns objectId.
903
+ // Absence is a transport / protocol deviation — never paper over
904
+ // it with a fabricated sequenceStatus:COMPLETED.
905
+ return {
906
+ performed: { errcode: performed.errcode, sheetCode, resolvedFrom, formType, draftObjectId },
907
+ readback: { formType, sequenceStatus: null, missingObjectId: true },
908
+ };
909
+ }
910
+ if (targetObjectId && objectId !== targetObjectId) {
911
+ throw new ToolRefusalError({
912
+ status: "refused",
913
+ blocker: "submit-target-id-changed",
914
+ expectedBizObjectId: targetObjectId,
915
+ actualBizObjectId: objectId,
916
+ message: "runtime/form/submit returned a different object id; refusing to accept a new row",
917
+ });
918
+ }
919
+ const load = await wireCall(session, {
920
+ path: ENDPOINTS.runtimeFormLoad,
921
+ query: { schemaCode: args.schemaCode, sheetCode, objectId },
922
+ });
923
+ const loaded = (load.data ?? {});
924
+ const record = normalizeLoadedRecord(loaded) ?? {};
925
+ const sequenceStatus = extractSequenceStatus(record);
926
+ readback = {
927
+ ...readback,
928
+ objectId,
929
+ schemaCode: typeof performedData.schemaCode === "string" ? performedData.schemaCode : args.schemaCode,
930
+ formCode: typeof performedData.formCode === "string" ? performedData.formCode : sheetCode,
931
+ sequenceStatus,
932
+ ...(sequenceStatus === null ? { missingSequenceStatus: true } : {}),
933
+ submittedData: projectReadbackRecord(record, args),
934
+ };
935
+ }
936
+ else {
937
+ const returnedObjectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
938
+ if (targetObjectId && returnedObjectId !== targetObjectId) {
939
+ throw new ToolRefusalError({
940
+ status: "refused",
941
+ blocker: "submit-target-id-changed",
942
+ expectedBizObjectId: targetObjectId,
943
+ actualBizObjectId: returnedObjectId,
944
+ message: "runtime/form/submit returned a different object id; refusing to accept a new row",
945
+ });
946
+ }
947
+ const workflowInstanceId = typeof performedData.workflowInstanceId === "string" ? performedData.workflowInstanceId : null;
948
+ const objectId = returnedObjectId ?? targetObjectId;
949
+ // Workflow engine status writes can lag the submit response
950
+ // (autoCommit): an immediate authoritative form/load may still show
951
+ // the pre-submit status. Poll briefly when a workflow submit reads
952
+ // back unchanged — a genuinely unchanged mid-flow status costs one
953
+ // short bounded wait, a lagged boundary transition resolves.
954
+ const preSubmitStatus = preSubmitExisting ? extractSequenceStatus(preSubmitExisting) : null;
955
+ let loaded = objectId ? await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId) : null;
956
+ let sequenceStatus = extractSequenceStatus(loaded);
957
+ for (let attempt = 0; objectId && sequenceStatus !== null && sequenceStatus === preSubmitStatus && attempt < 3; attempt += 1) {
958
+ await new Promise((resolve) => setTimeout(resolve, 400));
959
+ loaded = await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId);
960
+ sequenceStatus = extractSequenceStatus(loaded);
961
+ }
962
+ const loadedRecord = normalizeLoadedRecord(loaded);
963
+ // The platform's submit response often omits workflowInstanceId (the fake
964
+ // start path returns only {result, objectId}); the authoritative loaded
965
+ // row carries it. Prefer the response, then the row.
966
+ const effectiveInstanceId = workflowInstanceId ?? extractWorkflowInstanceId(loaded) ?? (loadedRecord && typeof loadedRecord.workflowInstanceId === "string" && loadedRecord.workflowInstanceId ? loadedRecord.workflowInstanceId : null);
967
+ readback = {
968
+ ...readback,
969
+ workflowInstanceId: effectiveInstanceId,
970
+ objectId,
971
+ workItemId: typeof performedData.workItemId === "string" ? performedData.workItemId : null,
972
+ sequenceStatus,
973
+ submittedData: projectReadbackRecord(loadedRecord, args),
974
+ };
975
+ // Multi-level approval: after a submit advances the flow, the caller
976
+ // almost always needs the NEXT pending todo — bench52/53 paid an extra
977
+ // `workitem.list` wire call at every approval level because the submit
978
+ // response carried only the object id. Attach the pending work items for
979
+ // this instance while the flow is still in flight (best-effort: a lookup
980
+ // failure must never fail the submit).
981
+ if (hasWorkflow && effectiveInstanceId && sequenceStatus !== "COMPLETED") {
982
+ try {
983
+ const pending = await searchWorkItems(session, { instanceId: effectiveInstanceId });
984
+ const nextWorkItems = pending.items.slice(0, 5).map((item) => ({
985
+ workItemId: wireString(item.id),
986
+ activityCode: wireString(item.activityCode),
987
+ activityName: wireString(item.activityName),
988
+ state: wireString(item.state),
989
+ }));
990
+ if (nextWorkItems.length > 0) {
991
+ readback.nextWorkItems = nextWorkItems;
992
+ // The submit RESPONSE's own workItemId is null (it names the item
993
+ // just completed, not the next one). Expose the fully-formed args
994
+ // for the NEXT approval so a multi-level flow advances without an
995
+ // extra workitem.list round or guessing which id to use.
996
+ readback.nextSubmit = {
997
+ schemaCode: args.schemaCode,
998
+ workItemId: nextWorkItems[0].workItemId,
999
+ workflowInstanceId: effectiveInstanceId,
1000
+ bizObjectId: objectId,
1001
+ note: "下一级审批:用这些字段调 data.submit(同意传 agree:true / formData 回填)。",
1002
+ };
1003
+ }
1004
+ }
1005
+ catch {
1006
+ // best-effort
1007
+ }
1008
+ }
1009
+ }
1010
+ return {
1011
+ performed: {
1012
+ errcode: performed.errcode,
1013
+ sheetCode,
1014
+ resolvedFrom,
1015
+ formType,
1016
+ ...(draftObjectId ? { draftObjectId } : {}),
1017
+ // The workflow was auto-detected and started (formType=1) rather than
1018
+ // saved as a bare sheet row — surfaced so the caller knows the row is
1019
+ // now a PROCESSING workflow instance, not an orphan sheet record.
1020
+ ...(autoStarted ? { autoStartedWorkflow: explicitWorkflowCode } : {}),
1021
+ },
1022
+ readback: {
1023
+ ...readback,
1024
+ // Portal-required semantics are NOT enforced by the REST submit path;
1025
+ // say exactly what the tool checked so `ok` is never mistaken for
1026
+ // "the portal would have accepted this" (dogfood R3). This is a
1027
+ // SEPARATE concern from server-side business rules (which DO run on
1028
+ // submit) — name that so `portalRulesExecuted:false` is not misread as
1029
+ // "no rule ran" (dogfood R2).
1030
+ portalRulesExecuted: false,
1031
+ portalRulesNote: "portalRulesExecuted 只指门户客户端的必填/控件校验未在 REST 提交时执行;服务端业务规则(含流程 end 规则)照常运行。",
1032
+ requiredFields: validation.requiredFields,
1033
+ missingRequired: validation.missingRequired,
1034
+ ...(validation.missingRequired.length > 0
1035
+ ? {
1036
+ portalValidationWarning: `已提交但**未执行门户必填校验**:${validation.missingRequired.join("、")} 在发布表单里是必填,本次 formData 为空/缺省。REST runtime/form/submit 不跑门户客户端校验;如需真正阻断,请在提交前自行校验,或在服务端规则里加必填约束。`,
1037
+ }
1038
+ : {}),
1039
+ // Round 16b (dogfood R1-R3): a submit can leave BOTH a draft row and a
1040
+ // committed row, and deleting only the committed id leaves the draft
1041
+ // behind (data.list still counts it). Name both ids so cleanup does not
1042
+ // silently miss one. Round 17 (dogfood R2): when there is only ONE row
1043
+ // (a workflow submit advances the same row), say so explicitly — the
1044
+ // absence of a hint was itself read as an evidence gap.
1045
+ ...(typeof readback.objectId === "string"
1046
+ ? draftObjectId && readback.objectId !== draftObjectId
1047
+ ? { cleanupHint: `本次提交产生 draft(${draftObjectId}) 与 committed(${String(readback.objectId)}) 两条记录;验收后两条都要 data.delete,直到 data.list total=0。` }
1048
+ : { cleanupHint: `本次提交只有一条记录(${String(readback.objectId)});验收后 data.delete 该 id,直到 data.list total=0。` }
1049
+ : {}),
1050
+ },
1051
+ };
1052
+ });
1053
+ }
1054
+ //# sourceMappingURL=runtime-data.js.map