@stalfh233/omc-cli 0.6.13 → 0.6.14
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.
- package/dist/cli.js +34 -5
- package/dist/discovery.js +4 -0
- package/dist/lanes/app.js +1 -1
- package/dist/lanes/field-contract.js +2 -2
- package/dist/lanes/field.js +78 -4
- package/dist/lanes/list-view-contract.js +1 -1
- package/dist/lanes/list-view.js +45 -10
- package/dist/path-projection.js +14 -1
- package/dist/recipes/b2-registration.js +1 -1
- package/dist/recipes/form.js +2 -2
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -883,6 +883,18 @@ function projectArtifactPath(value, path) {
|
|
|
883
883
|
? { missed: true, ...(fanout.at !== undefined ? { at: fanout.at } : {}), ...(fanout.available !== undefined ? { available: fanout.available } : {}) }
|
|
884
884
|
: { missed: false, content: fanout.values, mapped: fanout.values.length, ...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries } : {}), nullEntries: fanout.nullEntries };
|
|
885
885
|
}
|
|
886
|
+
/** Preserve source completeness/count facts on every artifact projection. */
|
|
887
|
+
function artifactProjectionMetadata(value) {
|
|
888
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
889
|
+
return {};
|
|
890
|
+
const record = value;
|
|
891
|
+
const metadata = {};
|
|
892
|
+
for (const key of ["truncated", "truncatedKeys", "itemsCount", "total"]) {
|
|
893
|
+
if (Object.prototype.hasOwnProperty.call(record, key))
|
|
894
|
+
metadata[key] = record[key];
|
|
895
|
+
}
|
|
896
|
+
return metadata;
|
|
897
|
+
}
|
|
886
898
|
/**
|
|
887
899
|
* T6(GitLab #17):`mapped`/`nullEntries` 的读法要跟数据一起走,否则 `mapped:5`
|
|
888
900
|
* 会被读成"取到 5 个值"。文案的单一真相源在 path-projection(与 `--extract`
|
|
@@ -938,15 +950,17 @@ async function cmdArtifactGet(id, transport, options = {}) {
|
|
|
938
950
|
// 一层 `projection:{path,content}`——`artifact get --jq` 与 `--summary --jq`
|
|
939
951
|
// 因此同形(顶层 path/content),换读面不用重新探根键。
|
|
940
952
|
return emitTransportResult(projected.missed
|
|
941
|
-
? { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, unanswered: true, hint: artifactPathMissHint(projected.at) }
|
|
953
|
+
? { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, ...artifactProjectionMetadata(content), keys, path: options.jq, content: null, projections: [{ path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }], pathMiss: true, missedAt: projected.at, available: projected.available, unanswered: true, hint: artifactPathMissHint(projected.at) }
|
|
942
954
|
: {
|
|
943
955
|
status: "ok",
|
|
944
956
|
artifactId: id,
|
|
945
957
|
sha256: artifact.sha256,
|
|
946
958
|
bytes: artifact.content.length,
|
|
959
|
+
...artifactProjectionMetadata(content),
|
|
947
960
|
keys,
|
|
948
961
|
path: options.jq,
|
|
949
962
|
content: projected.content ?? null,
|
|
963
|
+
projections: [{ path: options.jq, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}), ...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}) }],
|
|
950
964
|
...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}),
|
|
951
965
|
...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}),
|
|
952
966
|
...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}),
|
|
@@ -992,12 +1006,12 @@ async function cmdArtifactGet(id, transport, options = {}) {
|
|
|
992
1006
|
? { path, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }
|
|
993
1007
|
: { path, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}), ...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}), ...(artifactNullHint(projected.content, projected.nullEntries) !== undefined ? { hint: artifactNullHint(projected.content, projected.nullEntries) } : {}) };
|
|
994
1008
|
});
|
|
995
|
-
return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, projections, ...(projections.some((entry) => entry.pathMiss) ? { unanswered: true, hint: partialPathMissHint() } : {}) }, transport);
|
|
1009
|
+
return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, ...artifactProjectionMetadata(content), projections, ...(projections.some((entry) => entry.pathMiss) ? { unanswered: true, hint: partialPathMissHint() } : {}) }, transport);
|
|
996
1010
|
}
|
|
997
1011
|
const projected = projectArtifactPath(artifactContent, options.jq);
|
|
998
1012
|
return emitTransportResult(projected.missed
|
|
999
|
-
? { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, unanswered: true, hint: artifactPathMissHint(projected.at) }
|
|
1000
|
-
: { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}), ...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}), ...(artifactNullHint(projected.content, projected.nullEntries) !== undefined ? { hint: artifactNullHint(projected.content, projected.nullEntries) } : {}) }, transport);
|
|
1013
|
+
? { status: "ok", artifactId: id, sha256: artifact.sha256, ...artifactProjectionMetadata(content), path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, unanswered: true, hint: artifactPathMissHint(projected.at) }
|
|
1014
|
+
: { status: "ok", artifactId: id, sha256: artifact.sha256, ...artifactProjectionMetadata(content), path: options.jq, content: projected.content ?? null, projections: [{ path: options.jq, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}), ...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}) }], ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}), ...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}), ...(artifactNullHint(projected.content, projected.nullEntries) !== undefined ? { hint: artifactNullHint(projected.content, projected.nullEntries) } : {}) }, transport);
|
|
1001
1015
|
}
|
|
1002
1016
|
// r6-onboarding 2026-10-21: 47 artifact fetches, 0 used a projection; the 10
|
|
1003
1017
|
// large ones (up to 55 KB) pulled whole workflow templates just to read one
|
|
@@ -1894,7 +1908,22 @@ function cmdHelpSearch(topic, query, transport, searchMode, limit) {
|
|
|
1894
1908
|
}
|
|
1895
1909
|
if (all) {
|
|
1896
1910
|
console.error(`omc help ${document.name} --all(${Buffer.byteLength(document.content, "utf8")} B)`);
|
|
1897
|
-
|
|
1911
|
+
// r32 F3(真机):三种模式三种形状——默认回 sections[]、--section 回顶层 content、
|
|
1912
|
+
// --all 却回 {topic:{name,description,content}}。按前两种写的解析(.content /
|
|
1913
|
+
// .result.topic 当字符串)在 --all 上全部取不到,而默认模式的 hint 恰恰把 --all
|
|
1914
|
+
// 指成"确需整篇"的下一步。
|
|
1915
|
+
// 现在 --all 与前两者同形:topic 是 topic 名(字符串),正文在顶层 content,
|
|
1916
|
+
// 并附 bytes/chars/complete。**不要再把 content 放进 topic**——那会让整篇在
|
|
1917
|
+
// 信封里出现两次,撑破 64KiB 预算后被外置成 artifact(r32 实测:help-write-hazards
|
|
1918
|
+
// 因此从"直接回正文"退化成"回一个每次不同的 artifactId",golden 永久漂移)。
|
|
1919
|
+
return emitTransportResult({
|
|
1920
|
+
status: "ok",
|
|
1921
|
+
topic: document.name,
|
|
1922
|
+
content: document.content,
|
|
1923
|
+
bytes: Buffer.byteLength(document.content, "utf8"),
|
|
1924
|
+
chars: document.content.length,
|
|
1925
|
+
complete: true,
|
|
1926
|
+
}, transport);
|
|
1898
1927
|
}
|
|
1899
1928
|
// Shape first. Round 10 paid 35,872 B per full read of one topic, seven times
|
|
1900
1929
|
// in a single session, to use one or two headings. The index names every
|
package/dist/discovery.js
CHANGED
|
@@ -485,6 +485,10 @@ export function describeTool(registry, name) {
|
|
|
485
485
|
// High-frequency read faces: declare the RESULT keys so `--jq` does not
|
|
486
486
|
// have to learn them from a pathMiss. Zero bytes when undeclared.
|
|
487
487
|
...(returnsFace(tool.name) ?? {}),
|
|
488
|
+
// r32 F1(真机):探针并发发两条 app.create,第二条回 write-lock-held。
|
|
489
|
+
// 撞上之前没有任何发现面说过"写是环境级串行"——describe 的 params 里没有 wait
|
|
490
|
+
// (它是 omc call 的 CLI 开关,不在工具 schema),capability notes 是空的。
|
|
491
|
+
...(tool.kind === "write" ? { writeLock: "写锁=环境级:同一环境的并发写,后到的直接回 write-lock-held(不排队)。可带 --wait <ms> 让 CLI 内部排队(CLI 开关,不是本工具参数);不要写 shell 重试循环" } : {}),
|
|
488
492
|
// Keep the cost salient at the decision point: agents reached for --full even
|
|
489
493
|
// when --example/--param existed (sandbox22: 8 --full calls, one 19 KB).
|
|
490
494
|
full: `omc describe ${tool.name} --full(5–20KB;先试 --example/--param)`,
|
package/dist/lanes/app.js
CHANGED
|
@@ -80,7 +80,7 @@ export function registerAppLane(tools) {
|
|
|
80
80
|
lane: APP_LANE,
|
|
81
81
|
scope: APP_TOOLS["app.create"].scope,
|
|
82
82
|
description: APP_TOOLS["app.create"].description,
|
|
83
|
-
inputSchema: { code: z.string().min(1), name: z.string().min(1), groupId: z.string().optional() },
|
|
83
|
+
inputSchema: { code: z.string().min(1), name: z.string().min(1), groupId: z.string().describe("省略时使用环境配置的默认应用分组(回执 resolvedFrom=auto-configured-app-group),不需要先查 groupId").optional() },
|
|
84
84
|
execute: async (session, args) => {
|
|
85
85
|
const existing = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).find((row) => (row.appCode ?? row.code) === args.code);
|
|
86
86
|
if (existing)
|
|
@@ -190,7 +190,7 @@ export const FIELD_LANE_CONTRACT = Object.freeze({
|
|
|
190
190
|
}),
|
|
191
191
|
"field.publish": Object.freeze({
|
|
192
192
|
scope: "create publish readback",
|
|
193
|
-
description: "Publish the model's draft fields (bizproperty/publish). **Model-scoped**: one call publishes every draft field — never once per field. Optional `code`/`codes` verify published face; readback 回 `publishedCodes`。`codes` 短路须草稿/发布 options 一致,否则真发布。formBinding 可含 dictionaryStale / relation-mappings-empty。瞬态重试(ticket 79):异常小资源下 DDL 中间态自动重试≤3(退避 2s/5s);仍失败等≥30s
|
|
193
|
+
description: "Publish the model's draft fields (bizproperty/publish). **Model-scoped**: one call publishes every draft field — never once per field. Optional `code`/`codes` verify published face; readback 回 `publishedCodes`。`codes` 短路须草稿/发布 options 一致,否则真发布。formBinding 可含 dictionaryStale / relation-mappings-empty。瞬态重试(ticket 79):异常小资源下 DDL 中间态自动重试≤3(退避 2s/5s);仍失败等≥30s 再发,勿重复建字段。**部分交付口径**:有请求的 code 不在发布面时 `allRequestedPresent:false` + `verified:false` + `readback.present:false`(共享写门据此把 deliveryState 降为 WRITE_UNVERIFIED)+ `unpublishedRequested`,并区分 `nonexistentCodes`(草稿面都没有——先 field.list 核对)与 `stillDraftCodes`(模型里有、发布后仍不在发布面)——已发布的字段仍然算发布了,但不要只看 ok/DELIVERED。",
|
|
194
194
|
}),
|
|
195
195
|
"field.delete": Object.freeze({
|
|
196
196
|
scope: "create publish readback",
|
|
@@ -219,7 +219,7 @@ export function assertFieldContract() {
|
|
|
219
219
|
throw new Error("field contract pins the publish scope to model");
|
|
220
220
|
assertDescriptionCarries(FIELD_LANE_CONTRACT, "field.create", ["bizproperty/create", "OCR/ELECTRONIC_SIGN", "`choices`", "options.optionsSet", "`relativeCode`", "searchFormula", "`options.formulaConfig`", "`options.relevanceField`", "code_reserved", "WORK_SHEET"]);
|
|
221
221
|
assertDescriptionCarries(FIELD_LANE_CONTRACT, "field.update", ["bizproperty/update", "`options`", "field.publish", "cascadeRepublish", "form.draft(rebuild)", "form.publish(force)", "published fields", "non-structural", "partial"]);
|
|
222
|
-
assertDescriptionCarries(FIELD_LANE_CONTRACT, "field.publish", ["bizproperty/publish", "published", "Model-scoped", "publishedCodes"]);
|
|
222
|
+
assertDescriptionCarries(FIELD_LANE_CONTRACT, "field.publish", ["bizproperty/publish", "published", "Model-scoped", "publishedCodes", "unpublishedRequested", "nonexistentCodes", "stillDraftCodes"]);
|
|
223
223
|
assertDescriptionCarries(FIELD_LANE_CONTRACT, "field.delete", ["bizproperty/force_delete", "absence"]);
|
|
224
224
|
}
|
|
225
225
|
//# sourceMappingURL=field-contract.js.map
|
package/dist/lanes/field.js
CHANGED
|
@@ -197,7 +197,7 @@ export function registerFieldLane(tools) {
|
|
|
197
197
|
lane: FIELD_LANE,
|
|
198
198
|
scope: FIELD_TOOLS["field.list"].scope,
|
|
199
199
|
description: FIELD_TOOLS["field.list"].description,
|
|
200
|
-
inputSchema: { environment: environmentArg, schemaCode: z.string(), published: z.boolean().default(false), code: z.string().optional(), fields: z.array(z.string()).optional(), full: z.boolean().optional().describe("行内联完整 options/subSchema;默认清单面只有 code/name/type/typeName(+conditions 信号/shapeWarning),验证 options 用 code/fields 选择器或 full:true"), all: z.boolean().optional().describe("默认只返业务字段(过滤 SYSTEM_FIELD_CODES 的 13 个平台字段:id/name/sequenceNo/createdTime/creater/sequenceStatus/…);true 连系统字段一起返"), capability: capabilityArg },
|
|
200
|
+
inputSchema: { environment: environmentArg, schemaCode: z.string(), published: z.boolean().default(false), code: z.string().optional(), fields: z.array(z.string()).optional(), full: z.boolean().optional().describe("行内联完整 options/subSchema;默认清单面只有 code/name/type/typeName(+conditions 信号/shapeWarning),验证 options 用 code/fields 选择器或 full:true;平台读面带回 propertyEmpty 时一并输出(不回则省略,不塞 false)"), all: z.boolean().optional().describe("默认只返业务字段(过滤 SYSTEM_FIELD_CODES 的 13 个平台字段:id/name/sequenceNo/createdTime/creater/sequenceStatus/…);true 连系统字段一起返"), capability: capabilityArg },
|
|
201
201
|
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
202
202
|
const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: args.published ? "true" : "false" } })).data);
|
|
203
203
|
const mapped = rows.map((row) => {
|
|
@@ -230,6 +230,11 @@ export function registerFieldLane(tools) {
|
|
|
230
230
|
: {}),
|
|
231
231
|
...(row.relativeCode ? { relativeCode: row.relativeCode } : {}),
|
|
232
232
|
...(typeof row.relativePropertyCode === "string" && row.relativePropertyCode ? { relativePropertyCode: row.relativePropertyCode } : {}),
|
|
233
|
+
// r32:propertyEmpty 只在平台读面**确实**带回该值时才输出——缺键不给
|
|
234
|
+
// 每个字段塞 false(假读回 + 噪音)。真机 8.6.26 的 bizproperty/list
|
|
235
|
+
// 不带这个键,于是不会出现;write-only 的确认走 field.update 的
|
|
236
|
+
// readback.committed。
|
|
237
|
+
...(Object.prototype.hasOwnProperty.call(row, "propertyEmpty") ? { propertyEmpty: row.propertyEmpty } : {}),
|
|
233
238
|
...(options !== undefined ? { options } : {}),
|
|
234
239
|
...(scaleWarning !== undefined ? { shapeWarning: scaleWarning } : {}),
|
|
235
240
|
};
|
|
@@ -454,7 +459,7 @@ export function registerFieldLane(tools) {
|
|
|
454
459
|
choices: choicesArg,
|
|
455
460
|
relativeCode: relativeCodeArg,
|
|
456
461
|
subFields: subFieldArg,
|
|
457
|
-
propertyEmpty: z.boolean().optional().describe("覆盖数据模型层的不可为空声明;传 false
|
|
462
|
+
propertyEmpty: z.boolean().optional().describe("覆盖数据模型层的不可为空声明;传 false 可把历史误建的字段改回可为空(必填/只读应在表单与流程节点配置)。readback.committed 列出本次实际提交到线缆的键,readback.propertyEmpty 只在草稿面回读到该值时才出现(r32 真机:bizproperty/list 行不带 propertyEmpty,读不到即不编造,用 committed 确认本次提交)"),
|
|
458
463
|
syncDictionary: z
|
|
459
464
|
.boolean()
|
|
460
465
|
.optional()
|
|
@@ -1042,6 +1047,21 @@ async function runFieldUpdate(session, args) {
|
|
|
1042
1047
|
// confirm the write landed — the readback only named the field. Field
|
|
1043
1048
|
// options are the form-interaction contract (ADR 0005), so read them back.
|
|
1044
1049
|
const storedOptions = after !== undefined ? parseFieldOptionsForReadback(after.options) : undefined;
|
|
1050
|
+
// r32(真机复现:field.update propertyEmpty:true 回 ok/DELIVERED,field.list --full
|
|
1051
|
+
// 与 model.inspect 都看不到该值):平台字段行读面(bizproperty/list 与
|
|
1052
|
+
// bizproperty/get)都不回 propertyEmpty,这是**只写属性**。不编造读回值:
|
|
1053
|
+
// - `propertyEmpty` 只在草稿行确实带该键时才输出(不与 null 比较);否则省略;
|
|
1054
|
+
// - `committed` 列出本次线缆体里实际提交的键,调用方据此确认「提交了什么」。
|
|
1055
|
+
const submittedPropertyEmpty = args.propertyEmpty;
|
|
1056
|
+
const hasReadBackPropertyEmpty = after !== undefined && Object.prototype.hasOwnProperty.call(after, "propertyEmpty");
|
|
1057
|
+
const committedKeys = [
|
|
1058
|
+
...(args.name !== undefined ? ["name"] : []),
|
|
1059
|
+
...(args.name_i18n !== undefined ? ["name_i18n"] : []),
|
|
1060
|
+
...(effectiveOptions !== undefined || args.choices !== undefined ? ["options"] : []),
|
|
1061
|
+
...(args.relativeCode !== undefined ? ["relativeCode"] : []),
|
|
1062
|
+
...(args.propertyEmpty !== undefined ? ["propertyEmpty"] : []),
|
|
1063
|
+
];
|
|
1064
|
+
const unverifiable = submittedPropertyEmpty !== undefined && !hasReadBackPropertyEmpty ? ["propertyEmpty"] : [];
|
|
1045
1065
|
return {
|
|
1046
1066
|
performed: { errcode: performed.errcode },
|
|
1047
1067
|
...(prepared.conditionCompiled !== undefined && prepared.conditionCompiled.length > 0
|
|
@@ -1053,6 +1073,14 @@ async function runFieldUpdate(session, args) {
|
|
|
1053
1073
|
...(after?.name !== undefined ? { name: after.name } : {}),
|
|
1054
1074
|
...(typeof after?.name_i18n === "string" ? { name_i18n: after.name_i18n } : {}),
|
|
1055
1075
|
...(after?.type !== undefined || after?.propertyType !== undefined ? { type: after?.type ?? after?.propertyType } : {}),
|
|
1076
|
+
...(hasReadBackPropertyEmpty ? { propertyEmpty: after?.propertyEmpty } : {}),
|
|
1077
|
+
committed: committedKeys,
|
|
1078
|
+
...(unverifiable.length > 0
|
|
1079
|
+
? {
|
|
1080
|
+
unverifiable,
|
|
1081
|
+
unverifiableNote: "propertyEmpty 是只写属性:平台字段读面(bizproperty/list|get)不回该键,无法读回核对。本回执只证明它已随本次请求提交。",
|
|
1082
|
+
}
|
|
1083
|
+
: {}),
|
|
1056
1084
|
...(storedOptions !== undefined && storedOptions !== null ? { options: storedOptions } : {}),
|
|
1057
1085
|
...(dictionarySynced !== undefined ? { dictionarySynced } : {}),
|
|
1058
1086
|
...(formConditionSync !== undefined ? { formConditionSync } : {}),
|
|
@@ -1245,6 +1273,18 @@ function fieldTypeName(row) {
|
|
|
1245
1273
|
const raw = row.typeName ?? fieldTypeLabel(row.type ?? row.propertyType);
|
|
1246
1274
|
return String(raw ?? "");
|
|
1247
1275
|
}
|
|
1276
|
+
/**
|
|
1277
|
+
* r32: 一句话说清「哪些 code 没到位、下一步怎么核对」。不存在的 code 指向
|
|
1278
|
+
* field.list(拼错/尚未创建),仍留在草稿面的 code 指向平台发布异常。
|
|
1279
|
+
*/
|
|
1280
|
+
function signPartialDelivery(missing, nonexistent, stillDraft) {
|
|
1281
|
+
const parts = [];
|
|
1282
|
+
if (nonexistent.length > 0)
|
|
1283
|
+
parts.push(`${nonexistent.join("、")} 在模型里不存在(草稿面也没有):先 field.list 核对 code(omc call field.list --args '{"schemaCode":"<schemaCode>"}'),或先 field.create 建字段——本次发布不会建字段`);
|
|
1284
|
+
if (stillDraft.length > 0)
|
|
1285
|
+
parts.push(`${stillDraft.join("、")} 模型里有但发布后仍不在发布面:按发布失败排查(查看平台日志后重试本工具,模型级发布可重复执行)`);
|
|
1286
|
+
return `部分交付(unpublishedRequested: ${missing.join("、")}):已发布的字段确实已发布,但这些请求的 code 不在发布面。${parts.join(";")}`;
|
|
1287
|
+
}
|
|
1248
1288
|
export async function executeFieldPublish(session, args) {
|
|
1249
1289
|
// bizproperty/publish is MODEL-SCOPED: one call publishes every draft
|
|
1250
1290
|
// field of the schema. `code`/`codes` only select which fields to verify
|
|
@@ -1289,6 +1329,10 @@ export async function executeFieldPublish(session, args) {
|
|
|
1289
1329
|
const mismatches = checked.filter((entry) => entry.kind === "mismatch");
|
|
1290
1330
|
return { mode: "detail", verified: true, checked: bounded.length, ok: mismatches.length === 0, ...(mismatches.length > 0 ? { mismatches } : {}) };
|
|
1291
1331
|
};
|
|
1332
|
+
// r32: 请求里不存在的 code 不能靠「发布面缺它」推断——发布面缺也可能是这次
|
|
1333
|
+
// 发布才补上的。先记录 DRAFT 面(= 模型里真实存在的字段)的 code 集合,
|
|
1334
|
+
// 用来把「不存在」与「未发布」分开报。
|
|
1335
|
+
const draftCodesBefore = new Set(draftSnapshot.keys());
|
|
1292
1336
|
const preRead = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
|
|
1293
1337
|
const prePublishedRows = wireRows(preRead.data);
|
|
1294
1338
|
const prePublished = prePublishedRows.map(codeOf).filter((code) => code.length > 0);
|
|
@@ -1375,8 +1419,22 @@ export async function executeFieldPublish(session, args) {
|
|
|
1375
1419
|
const publishedCodes = wireRows(readback.data).map(codeOf).filter((code) => code.length > 0);
|
|
1376
1420
|
const requested = [...(args.code ? [args.code] : []), ...(args.codes ?? [])];
|
|
1377
1421
|
const present = requested.filter((code) => publishedCodes.includes(code));
|
|
1422
|
+
// r32(真机复现 0.6.13):`codes:["g1","notExist"]` 回 ok + DELIVERED +
|
|
1423
|
+
// allRequestedPresent:false + verification.ok:true —— 三个成功标记与「有请求的
|
|
1424
|
+
// 字段不在发布面」同真。不在发布面的 code 分两类,报法不同:
|
|
1425
|
+
// nonexistentCodes —— 草稿面都没有(模型里根本不存在,多半是拼错);
|
|
1426
|
+
// stillDraftCodes —— 模型里有、发布后仍不在发布面(真异常)。
|
|
1427
|
+
// 两者都让这次交付只算**部分成功**:已发布的字段是真发布了,所以不整单
|
|
1428
|
+
// 判失败;但既有的 verified/ok、allRequestedPresent、verification.ok 都不能
|
|
1429
|
+
// 再报 true(派生的 readback.present=false 会让共享写门把 deliveryState 降为
|
|
1430
|
+
// 既有枚举值 WRITE_UNVERIFIED——刻意不新增枚举外的值)。
|
|
1431
|
+
const missingRequested = requested.filter((code) => !present.includes(code));
|
|
1432
|
+
const nonexistentCodes = missingRequested.filter((code) => !draftCodesBefore.has(code));
|
|
1433
|
+
const stillDraftCodes = missingRequested.filter((code) => draftCodesBefore.has(code));
|
|
1434
|
+
const allRequestedPresent = missingRequested.length === 0;
|
|
1378
1435
|
const targets = (requested.length > 0 ? requested : [...draftSnapshot.keys()]).filter((code) => publishedCodes.includes(code));
|
|
1379
|
-
const
|
|
1436
|
+
const verificationRun = await verifyFieldDetails(targets);
|
|
1437
|
+
const verification = allRequestedPresent ? verificationRun : { ...verificationRun, ok: false };
|
|
1380
1438
|
const mismatches = verification.mismatches;
|
|
1381
1439
|
if (mismatches !== undefined)
|
|
1382
1440
|
throw new ToolRefusalError({
|
|
@@ -1392,7 +1450,23 @@ export async function executeFieldPublish(session, args) {
|
|
|
1392
1450
|
publishedCodes,
|
|
1393
1451
|
...(args.code ? { publishedFieldPresent: publishedCodes.includes(args.code) } : {}),
|
|
1394
1452
|
...(args.codes ? { publishedFieldsPresent: Object.fromEntries(args.codes.map((code) => [code, publishedCodes.includes(code)])) } : {}),
|
|
1395
|
-
...(requested.length > 0
|
|
1453
|
+
...(requested.length > 0
|
|
1454
|
+
? {
|
|
1455
|
+
allRequestedPresent,
|
|
1456
|
+
verified: allRequestedPresent,
|
|
1457
|
+
// 共享写门(write.ts deliveryStateFor)读 readback.present:部分成功不
|
|
1458
|
+
// 能盖 DELIVERED。全部就位时不写这个键(只增语义,不改变成功回执)。
|
|
1459
|
+
...(allRequestedPresent ? {} : { present: false }),
|
|
1460
|
+
...(allRequestedPresent
|
|
1461
|
+
? {}
|
|
1462
|
+
: {
|
|
1463
|
+
unpublishedRequested: missingRequested,
|
|
1464
|
+
...(nonexistentCodes.length > 0 ? { nonexistentCodes } : {}),
|
|
1465
|
+
...(stillDraftCodes.length > 0 ? { stillDraftCodes } : {}),
|
|
1466
|
+
note: signPartialDelivery(missingRequested, nonexistentCodes, stillDraftCodes),
|
|
1467
|
+
}),
|
|
1468
|
+
}
|
|
1469
|
+
: {}),
|
|
1396
1470
|
verification,
|
|
1397
1471
|
// issue #24 主诉:publishedCodes 只证明「字段可用」,不等于「页面可见」。
|
|
1398
1472
|
formBinding: await formBindingAdvisory(session, args.schemaCode, wireRows(readback.data)),
|
|
@@ -12,7 +12,7 @@ export const LIST_VIEW_WIRE = Object.freeze({
|
|
|
12
12
|
configureDefault: "code omitted means the default view (code=schemaCode)",
|
|
13
13
|
configureColumns: "query/get then query/update: for each fieldCodes entry, append a missing column as visible, or repair an existing column's name/propertyType/displayFormat/visible in place (never skip an already-present column), then query/get readback; propertyType comes from published field metadata; NUMERICAL displayFormat is derived from the field's 0-based `options.scale` (0=integer,1=1dp,2=2dp,3=3dp…8=8dp) through the platform parseNumber table ({0:1,1:2,2:3,3:11,4:12,23:…, percent 0..:4/5/6/13/14/27..30, currency ¥ 2dp..:7/15/16/31..34} — verified live: scale 2 → displayFormat 3 = 2 decimals) and DATE displayFormat from options.format (1=yyyy-MM-dd,2=datetime,3=minute,4=yyyy-MM); the readback columns array carries {propertyCode,propertyType,displayFormat,visible,sumType} and added/updated name the fieldCodes touched",
|
|
14
14
|
configureSorts: "sorts -> querySorts [{propertyCode,propertyType,direction,sortKey,clientType}]; direction SortDirectionType numeric ASC(1)/DESC(2); omitted leaves the existing sorts untouched, provided replaces them",
|
|
15
|
-
configureConditions: "conditions -> queryConditions [{propertyCode,propertyType,displayType,filterType,defaultValue,options,startValue,endValue,shortcutFilter,choiceType,dateType,accurateSearch,showNull,includeSubData,buildTreeBy,visible}]; filterType is the comparison OPERATOR (QueryFilterType STRING enum: Eq/NotEq/Gt/Lt/Gte/Lte/Like/NotLike/IsNull/IsNotNull/In/NotIn/Between/NotBetween/Of/NotOf/…; omitted → the platform default for the propertyType; aliases operator/op); displayType INPUT(0)/RADIO(1)/CHECKBOX(2)/SELECT(3)/RANGE(4); choiceType SELECT(1)/POPUP(2)/TREE_LEFT(3); options is a JSON string (decoded on readback)",
|
|
15
|
+
configureConditions: "conditions -> queryConditions [{propertyCode,propertyType,displayType,filterType,defaultValue,options,startValue,endValue,shortcutFilter,choiceType,dateType,accurateSearch,showNull,includeSubData,buildTreeBy,visible}]; filterType is the comparison OPERATOR (QueryFilterType STRING enum: Eq/NotEq/Gt/Lt/Gte/Lte/Like/NotLike/IsNull/IsNotNull/In/NotIn/Between/NotBetween/Of/NotOf/…; omitted → the platform default for the propertyType; aliases operator/op and legacy options.op); displayType INPUT(0)/RADIO(1)/CHECKBOX(2)/SELECT(3)/RANGE(4); choiceType SELECT(1)/POPUP(2)/TREE_LEFT(3); options is a JSON string (decoded on readback); conditionReadback is equivalent only for field-by-field equality, otherwise coerced with from/to details; viewConditionsAppliedRest:false records that REST data.list --queryCode does not apply fixed conditions",
|
|
16
16
|
configureConditionMap: "queryConditionMap {<schemaCode>: queryConditions[]} is read-derived (query/get with a formCode child-table filter); BizQueryDetailConfig persists queryConditions/querySorts only, so the map is not a configure input",
|
|
17
17
|
configureActions: "actions -> queryActions [{actionCode,name,icon,queryActionType,systemAction,serviceCode,serviceMethod,customService,associationCode,associationType,sortKey}]; queryActionType QueryActionType numeric QUERY(1)/ADD(2)/.../BATCH_SUBMIT(23); associationType QueryActionRelativeType numeric BIZSHEET(0)/WORKFLOW(1); associationCode/associationType are the JSON names of the DTO fields relativeCode/queryActionRelativeType (@JsonProperty); omitted leaves the existing actions untouched, provided MERGES into the effective set by actionCode (live 2026-09-17: query/update replaces-then-re-adds the submitted list, so a merge keeps the platform's built-in 新增/删除/导入/导出 and updates a matching actionCode — replaceActions:true restores the authoritative full set, defaultActions:true re-seeds the platform default set); query/get auto-injects default actions (qr_code/batch_print/batch_update/import_attachment/export_attachment/batch_submit) and query/update resets sortKey to i+1",
|
|
18
18
|
configurePresentation: "presentation -> queryPresentation {clientType,htmlJson,actionsJson,columnsJson,queryViewDataSource,schemaRelations,options}; htmlJson/actionsJson/columnsJson JSON strings are decoded once before render-shape validation (html element tagName+attrs, action actionCode/name/icon, column propertyCode/propertyType); invalid input refuses with listview-presentation-render-invalid before wire and is never double-encoded; clientType is forced to PC by query/update; schemaRelations is platform-derived from the queryColumns tree (resolveRelations) and overwrites your input; a non-empty htmlJson updates the environment record, which query/delete cleans up; omitted leaves the existing presentation untouched (the read-face object must be echoed, because saveBizQueryDetail removes every presentation whose clientType matches before re-adding), provided replaces it",
|
package/dist/lanes/list-view.js
CHANGED
|
@@ -28,6 +28,24 @@ function fieldOptions(options) {
|
|
|
28
28
|
}
|
|
29
29
|
return options && typeof options === "object" ? options : {};
|
|
30
30
|
}
|
|
31
|
+
function conditionOptionsWithOperator(condition) {
|
|
32
|
+
const options = condition.options;
|
|
33
|
+
if (typeof options !== "object" || options === null || Array.isArray(options))
|
|
34
|
+
return condition;
|
|
35
|
+
const optionRecord = options;
|
|
36
|
+
if (optionRecord.op === undefined)
|
|
37
|
+
return condition;
|
|
38
|
+
const aliases = { eq: "Eq", ne: "NotEq", neq: "NotEq", gt: "Gt", gte: "Gte", lt: "Lt", lte: "Lte", like: "Like" };
|
|
39
|
+
const raw = String(optionRecord.op);
|
|
40
|
+
return {
|
|
41
|
+
...condition,
|
|
42
|
+
...(condition.filterType === undefined && condition.operator === undefined ? { filterType: aliases[raw.toLowerCase()] ?? raw } : {}),
|
|
43
|
+
options: Object.fromEntries(Object.entries(optionRecord).filter(([key]) => key !== "op")),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function conditionValueEqual(left, right) {
|
|
47
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
48
|
+
}
|
|
31
49
|
/**
|
|
32
50
|
* QueryRuntimeController.parseNumber keys its formatting off displayFormat with
|
|
33
51
|
* no case 0 (valid values start at 1): 1=integer, 2=1dp, 3=2dp, 11=3dp,
|
|
@@ -650,10 +668,15 @@ export function registerListViewLane(tools) {
|
|
|
650
668
|
let requestedConditionKeys;
|
|
651
669
|
let requestedConditionSpec;
|
|
652
670
|
if (args.conditions !== undefined) {
|
|
653
|
-
const
|
|
671
|
+
const inputConditions = args.conditions.map(conditionOptionsWithOperator);
|
|
672
|
+
const normalizedConditions = normalizeConditions(inputConditions, fieldRows, args.schemaCode);
|
|
654
673
|
body.queryConditions = normalizedConditions;
|
|
655
674
|
requestedConditionKeys = normalizedConditions.map((row) => String(row.propertyCode ?? ""));
|
|
656
|
-
requestedConditionSpec =
|
|
675
|
+
requestedConditionSpec = inputConditions.map((requested, index) => ({
|
|
676
|
+
propertyCode: String(normalizedConditions[index]?.propertyCode ?? requested.propertyCode ?? ""),
|
|
677
|
+
requested,
|
|
678
|
+
normalized: normalizedConditions[index] ?? {},
|
|
679
|
+
}));
|
|
657
680
|
}
|
|
658
681
|
// saveBizQueryDetail removes every action whose clientType matches the
|
|
659
682
|
// update and re-adds the submitted list, so an omitted `actions` must echo
|
|
@@ -807,6 +830,8 @@ export function registerListViewLane(tools) {
|
|
|
807
830
|
if (conditions.length > 0)
|
|
808
831
|
readback.conditionRuntimeNote =
|
|
809
832
|
"视图条件的运行时过滤语义未取证:REST `data.list --queryCode <本视图>` 实测不应用固定条件(r35:读回 Gte/5000 正确、返回未过滤 4 条)。别用 queryCode 断言过滤生效;REST 侧过滤用 `data.list` 显式 `filter`。";
|
|
833
|
+
if (conditions.length > 0)
|
|
834
|
+
readback.viewConditionsAppliedRest = false;
|
|
810
835
|
// Post-write semantic readback (dogfood R2): the platform MODELS a list
|
|
811
836
|
// condition as a filter CONTROL definition, so a requested condition whose
|
|
812
837
|
// propertyCode does not survive the round-trip was lost. Surface that as
|
|
@@ -819,25 +844,35 @@ export function registerListViewLane(tools) {
|
|
|
819
844
|
// lands with the WRONG comparison is just as wrong as one that is
|
|
820
845
|
// dropped (dogfood R2 follow-up).
|
|
821
846
|
const storedByCode = new Map(conditions.map((condition) => [String(condition.propertyCode ?? ""), condition]));
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
847
|
+
const coercions = (requestedConditionSpec ?? []).flatMap((spec) => {
|
|
848
|
+
const stored = storedByCode.get(spec.propertyCode);
|
|
849
|
+
if (!stored)
|
|
850
|
+
return [{ propertyCode: spec.propertyCode, field: "condition", from: spec.requested, to: null }];
|
|
851
|
+
const fields = [
|
|
852
|
+
["filterType", spec.requested.filterType ?? spec.requested.operator ?? spec.requested.op ?? spec.normalized.filterType, stored.filterType],
|
|
853
|
+
["displayType", spec.requested.displayType, stored.displayType],
|
|
854
|
+
["defaultValue", spec.requested.defaultValue, stored.defaultValue],
|
|
855
|
+
["options", spec.requested.options, stored.options],
|
|
856
|
+
["startValue", spec.requested.startValue, stored.startValue],
|
|
857
|
+
["endValue", spec.requested.endValue, stored.endValue],
|
|
858
|
+
];
|
|
859
|
+
return fields.filter(([, from]) => from !== undefined).filter(([, from, to]) => !conditionValueEqual(from, to)).map(([field, from, to]) => ({ propertyCode: spec.propertyCode, field, from, to }));
|
|
860
|
+
});
|
|
861
|
+
const equivalent = missingConditions.length === 0 && coercions.length === 0;
|
|
827
862
|
readback.conditionReadback = {
|
|
828
|
-
status: equivalent ? "equivalent" : "
|
|
863
|
+
status: equivalent ? "equivalent" : "coerced",
|
|
829
864
|
requested: requestedConditionKeys,
|
|
830
865
|
stored: [...storedKeys],
|
|
831
866
|
// Operator-level summary so "operator + value" acceptance needs no
|
|
832
867
|
// `--full`/artifact fetch (dogfood R2 round17b).
|
|
833
868
|
operators: (requestedConditionSpec ?? []).map((spec) => ({
|
|
834
869
|
propertyCode: spec.propertyCode,
|
|
835
|
-
requested: spec.filterType ?? null,
|
|
870
|
+
requested: spec.requested.filterType ?? spec.requested.operator ?? spec.requested.op ?? spec.normalized.filterType ?? null,
|
|
836
871
|
stored: storedByCode.get(spec.propertyCode)?.filterType ?? null,
|
|
837
872
|
defaultValue: storedByCode.get(spec.propertyCode)?.defaultValue ?? null,
|
|
838
873
|
})),
|
|
839
874
|
...(missingConditions.length > 0 ? { missing: missingConditions } : {}),
|
|
840
|
-
...(
|
|
875
|
+
...(coercions.length > 0 ? { coercions } : {}),
|
|
841
876
|
};
|
|
842
877
|
}
|
|
843
878
|
readback.columnsRenderCompat = columnsRenderCompat(readArray(afterModel, "queryColumns"));
|
package/dist/path-projection.js
CHANGED
|
@@ -330,7 +330,7 @@ function isReceiptDerivedKey(key) {
|
|
|
330
330
|
// `detailFile` 故意**不在此列**:它是本地文件系统路径(进程内才有效),
|
|
331
331
|
// 合进投影视图会让 `--keys` 把本机路径当"结果键"吐出来(2026-11-05 回归:
|
|
332
332
|
// readpath-roundtrip 1.2 的 keys 断言多了 detailFile)。对外只认 artifactId。
|
|
333
|
-
return /(?:Count|Omitted)$/.test(key) || key === "detailArtifactId" || key === "detailSha256" || key === "from" || key === "truncated" || key === "truncatedKeys" || key === "fetch" || key === "tablesHint";
|
|
333
|
+
return /(?:Count|Omitted)$/.test(key) || key === "total" || key === "detailArtifactId" || key === "detailSha256" || key === "from" || key === "truncated" || key === "truncatedKeys" || key === "fetch" || key === "tablesHint";
|
|
334
334
|
}
|
|
335
335
|
function mergeReceiptDerivedKeys(artifactSource, result) {
|
|
336
336
|
if (artifactSource === null || typeof artifactSource !== "object" || Array.isArray(artifactSource))
|
|
@@ -438,6 +438,16 @@ export function projectResultExtract(result, paths, keysOnly) {
|
|
|
438
438
|
...(artifactId !== undefined ? { artifactId, sha256: artifact?.sha256 } : {}),
|
|
439
439
|
from: readFromArtifact ? "artifact" : "result",
|
|
440
440
|
};
|
|
441
|
+
// 投影只改变取值路径,不改变源结果的完整性事实。尤其是外置 artifact
|
|
442
|
+
// 场景,调用方仍必须能判断 items 是否被折叠,以及源侧 total/itemsCount。
|
|
443
|
+
// 只透传源中实际存在的键;未截断结果不要凭空写 truncated:false。
|
|
444
|
+
const sourceRecord = source && typeof source === "object" && !Array.isArray(source)
|
|
445
|
+
? source
|
|
446
|
+
: undefined;
|
|
447
|
+
for (const key of ["truncated", "truncatedKeys", "itemsCount", "total"]) {
|
|
448
|
+
if (sourceRecord !== undefined && Object.prototype.hasOwnProperty.call(sourceRecord, key))
|
|
449
|
+
out[key] = sourceRecord[key];
|
|
450
|
+
}
|
|
441
451
|
// T6:列表投影里「未配置的列」也是 null 键——工具自己已经写了 projectionHint
|
|
442
452
|
// (#15 A6),但投影层此前把它丢了,于是 `--jq items[].fields.x` 全 null 时
|
|
443
453
|
// 调用方看不到任何线索。把源结果的提示原样透传(有才带,零额外体积)。
|
|
@@ -570,6 +580,9 @@ export function projectResultExtract(result, paths, keysOnly) {
|
|
|
570
580
|
out.rowCount = rowCount;
|
|
571
581
|
// Deliberately NO top-level `content` on multi-path — that was the duplicate.
|
|
572
582
|
}
|
|
583
|
+
// Keep one machine-readable shape for artifact/call consumers. `content` on
|
|
584
|
+
// the single-path branch remains as a backwards-compatible convenience.
|
|
585
|
+
out.projections = extracts;
|
|
573
586
|
// A projection that missed is not an answer. `status` stays `ok` (adding a
|
|
574
587
|
// new status value would fall through classifyResult to error/exit 15 and
|
|
575
588
|
// change the exit contract), but the envelope now says so out loud.
|
|
@@ -54,7 +54,7 @@ const bizserviceSelfCallCorp = Object.freeze({
|
|
|
54
54
|
environment,
|
|
55
55
|
serviceCode: "<service_code>",
|
|
56
56
|
methodCode: "<method_code>",
|
|
57
|
-
|
|
57
|
+
methodInputParameters: [
|
|
58
58
|
{ code: "X-LowCode-Corpid", name: "corpId头", required: false, codeType: 0, bizCode: "<corpId 常量值>", configJson: { position: "REQUEST_HEADER" } },
|
|
59
59
|
],
|
|
60
60
|
},
|
package/dist/recipes/form.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { environment, model, commonRecovery } from "./shared.js";
|
|
2
2
|
const configureFormRecipe = Object.freeze({
|
|
3
3
|
id: "publish-default-form",
|
|
4
|
-
title: "
|
|
4
|
+
title: "把已发布字段绑进默认表单(发布请随后调用 form.publish)",
|
|
5
5
|
status: "offline-valid",
|
|
6
6
|
tool: "configure_form",
|
|
7
|
-
args: { environment, schemaCode: model
|
|
7
|
+
args: { environment, schemaCode: model },
|
|
8
8
|
preconditions: ["字段已 field.publish"],
|
|
9
9
|
conditionalRequirements: ["表单层条件必填/只读请用 field.update 的 options(form-conditional-required / form-always-readonly recipe),不是本编排器"],
|
|
10
10
|
priorToolReferences: [{ tool: "field.list", args: { environment, schemaCode: model, published: true }, purpose: "确认字段已发布" }],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stalfh233/omc-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.14",
|
|
4
4
|
"description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"commander": "14.0.3",
|
|
58
|
-
"omc-core": "0.6.
|
|
58
|
+
"omc-core": "0.6.14",
|
|
59
59
|
"proper-lockfile": "4.1.2",
|
|
60
60
|
"write-file-atomic": "6.0.0",
|
|
61
61
|
"zod": "^3.23.8",
|
|
@@ -73,6 +73,6 @@
|
|
|
73
73
|
"directory": "packages/omc-cli"
|
|
74
74
|
},
|
|
75
75
|
"optionalDependencies": {
|
|
76
|
-
"@stalfh233/omc-fake-platform": "0.6.
|
|
76
|
+
"@stalfh233/omc-fake-platform": "0.6.14"
|
|
77
77
|
}
|
|
78
78
|
}
|