@stalfh233/omc-cli 0.4.40 → 0.4.42

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.
@@ -905,10 +905,12 @@
905
905
  ],
906
906
  "lane": "online-js",
907
907
  "tools": [
908
- "onlinejs.compose"
908
+ "onlinejs.compose",
909
+ "onlinejs.get"
909
910
  ],
910
911
  "scopes": [
911
- "draft promotion publish verbatim readback"
912
+ "draft promotion publish verbatim readback",
913
+ "draft published script readback"
912
914
  ]
913
915
  },
914
916
  {
@@ -1276,4 +1278,4 @@
1276
1278
  ]
1277
1279
  }
1278
1280
  ]
1279
- }
1281
+ }
package/dist/call.js CHANGED
@@ -136,16 +136,22 @@ export async function runToolCall(toolName, options = {}) {
136
136
  }
137
137
  /**
138
138
  * The single stderr diagnostic line for a call result, or null when the result
139
- * is a bare success (the envelope already carries it). Errors keep their payload
140
- * because it is small and useful; a write's `summary` is the human-facing line.
139
+ * needs no human echo (the stdout envelope is the single machine truth). A
140
+ * write's `summary` is the human-facing line; errors keep their payload
141
+ * because it is small and useful. A DRY-RUN is a plan, not an error — its full
142
+ * body already lives in the envelope, and dumping it here doubled every
143
+ * dry-run payload on stderr (Windows live report 2026-10-14: two JSON lines,
144
+ * one raw + one escaped, read as "奇奇怪怪的乱码").
141
145
  */
142
146
  function humanResultLine(result) {
143
147
  if (typeof result.summary === "string" && result.summary.trim().length > 0)
144
148
  return result.summary;
145
149
  const status = String(result?.status ?? "ok");
146
- if (status !== "ok")
147
- return redact(result, []);
148
- return null;
150
+ if (status === "ok" || status === "dry-run")
151
+ return null;
152
+ if (result?.blocker === undefined && status !== "error" && status !== "uncertain")
153
+ return null;
154
+ return redact(result, []);
149
155
  }
150
156
  export function validateAgentPaths(args) {
151
157
  const root = resolve(process.env.OMC_CONFIG_HOME ?? process.cwd());
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
2
3
  export const CLI_EXIT_CODES = {
3
4
  ok: 0,
4
5
  invalidInput: 10,
@@ -38,9 +39,33 @@ export function asciiStdout() {
38
39
  return true;
39
40
  if (override === "0")
40
41
  return false;
41
- if (process.platform !== "win32")
42
+ return asciiStdoutFor(process.platform, process.stdout.isTTY === true, activeCodePage());
43
+ }
44
+ let cachedCodePage;
45
+ function activeCodePage() {
46
+ if (cachedCodePage !== undefined)
47
+ return cachedCodePage;
48
+ cachedCodePage = null;
49
+ try {
50
+ const chcp = spawnSync("chcp.com", [], { encoding: "utf8" });
51
+ cachedCodePage = /([0-9]{3,5})\s*$/.exec((chcp.stdout || "").trim().split("\n").pop() ?? "")?.[1] ?? null;
52
+ }
53
+ catch {
54
+ // chcp unavailable (modern terminal hosts) — assume UTF-8 rather than
55
+ // forcing \u escapes the user cannot read (Windows live 2026-10-14).
56
+ }
57
+ return cachedCodePage;
58
+ }
59
+ /** Pure core of asciiStdout for tests: platform × tty × active code page. */
60
+ export function asciiStdoutFor(platform, isTty, codePage) {
61
+ if (platform !== "win32")
62
+ return false;
63
+ if (isTty)
42
64
  return false;
43
- return process.stdout.isTTY !== true;
65
+ // Unknown code page (chcp unavailable — modern terminal hosts): trust
66
+ // UTF-8. Windows Terminal defaults to it, and \u escapes are unreadable to
67
+ // humans either way.
68
+ return codePage !== null && codePage !== "65001";
44
69
  }
45
70
  export function serializeEnvelope(value) {
46
71
  const json = JSON.stringify(value);
package/dist/cli.js CHANGED
@@ -30,6 +30,7 @@ import { listSkills, readSkill, readSkillSection } from "./skills.js";
30
30
  import { parseToolInput, ToolInputError } from "./tool-types.js";
31
31
  import { WireError, wireCall } from "./wire.js";
32
32
  import { ENDPOINTS } from "./endpoints.js";
33
+ import { cmdRollbackList, cmdRollbackPlan, cmdRollbackApply } from "./rollback.js";
33
34
  async function ask(question) {
34
35
  const rl = createInterface({ input: stdin, output: process.stderr });
35
36
  try {
@@ -953,6 +954,16 @@ async function main() {
953
954
  }
954
955
  commandExitCode = await runScript(lines, { defaultEnv: options.env, continueOnError: options.continue === true });
955
956
  });
957
+ const rollbackCommand = command.command("rollback").description("配置回滚(git-revert 语义):list 列写时间线 / plan 预览恢复(diff+后续写覆盖警告)/ apply 执行恢复——恢复走完整写闸门并记 journal(回滚可再回滚)。接入全部配置写面(workflow/规则/数据规则/视图/表单草稿/在线JS/字典/字段/模型/应用/报表);publish 类生命周期推进不可逆(plan 会说明);journal 与 before 快照为本机边界(不跨机器)");
958
+ rollbackCommand.command("list").option("--env <environment>").option("--environment <environment>").option("--target <substr>").option("--tool <name>").option("--limit <n>").action(async (options) => {
959
+ commandExitCode = await withTransport(options, "rollback list", (transport) => cmdRollbackList({ environment: options.env ?? options.environment, target: options.target, tool: options.tool, limit: options.limit }, transport));
960
+ });
961
+ rollbackCommand.command("plan <ref>").option("--env <environment>").option("--environment <environment>").action(async (ref, options) => {
962
+ commandExitCode = await withTransport(options, "rollback plan", (transport) => cmdRollbackPlan(ref, { environment: options.env ?? options.environment }, transport));
963
+ });
964
+ rollbackCommand.command("apply <ref>").option("--env <environment>").option("--environment <environment>").option("--confirm-production").action(async (ref, options) => {
965
+ commandExitCode = await withTransport(options, "rollback apply", (transport) => cmdRollbackApply(ref, { environment: options.env ?? options.environment, confirmProduction: options.confirmProduction === true }, transport));
966
+ });
956
967
  command.command("artifact").command("get <id>").option("--jq <path>").option("--path-only").option("--summary").option("--keys [a,b]").option("--max-output-bytes <bytes>").action(async (id, options) => {
957
968
  commandExitCode = await withTransport(options, request, (transport) => cmdArtifactGet(id, transport, { ...(options.jq ? { jq: options.jq } : {}), pathOnly: options.pathOnly === true, summary: options.summary === true || options.keys === true, ...(typeof options.keys === "string" ? { keys: options.keys } : {}) }));
958
969
  });
@@ -274,7 +274,7 @@ export function redundantFieldCodes(fields) {
274
274
  * ("表单不存在!") when the model has no form row yet — that is absence, not a
275
275
  * failure, so callers get null and may create it.
276
276
  */
277
- async function readFormOrNull(session, schemaCode, sheetCode) {
277
+ export async function readFormOrNull(session, schemaCode, sheetCode) {
278
278
  try {
279
279
  const result = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode, sheetCode } });
280
280
  return (result.data ?? null);
@@ -551,6 +551,26 @@ function controlCount(face) {
551
551
  * to bizsheet/update. Used by the form.draft write tool AND by configure_form,
552
552
  * so the high-level orchestrator never bypasses the scaffold.
553
553
  */
554
+ /** Ticket 74 pure gate (no session): caller-provided "[]"/"{}" layouts refuse. */
555
+ export function refuseEmptyCallerLayout(args) {
556
+ if (args.allowEmptyLayout === true)
557
+ return;
558
+ const emptyFaces = [];
559
+ if (typeof args.draftViewJson === "string" && isEmptyLayoutJson(args.draftViewJson))
560
+ emptyFaces.push("draftViewJson");
561
+ if (typeof args.draftAttributesJson === "string" && isEmptyLayoutJson(args.draftAttributesJson))
562
+ emptyFaces.push("draftAttributesJson");
563
+ if (emptyFaces.length === 0)
564
+ return;
565
+ throw new ToolRefusalError({
566
+ status: "refused",
567
+ blocker: "form-empty-layout",
568
+ schemaCode: args.schemaCode,
569
+ sheetCode: args.sheetCode,
570
+ faces: emptyFaces,
571
+ message: `显式提供的 ${emptyFaces.join("、")} 是空布局("[]"/"{}")——平台设计器 Save 会把草稿清成该形态(非无损),照写会丢控件。若确要清空布局请显式传 allowEmptyLayout:true;若是设计器 Save 后的修复,先用表单版本历史(/api/api/app/bizsheet/history/list)取回完好版本原文再写回`,
572
+ });
573
+ }
554
574
  export async function executeFormDraftWrite(session, args) {
555
575
  if (args.draftHtmlJson !== undefined) {
556
576
  assertHtmlRender(args.draftHtmlJson, { schemaCode: args.schemaCode, sheetCode: args.sheetCode, face: "draftHtmlJson" });
@@ -0,0 +1,73 @@
1
+ import { existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ /**
4
+ * Per-environment append-only journal of delivered governed config writes.
5
+ *
6
+ * Journal root = the SIBLING of the write-artifacts dir the governedWrite
7
+ * pipeline uses (ctx.artifactsDir, `<cwd>/.omc/artifacts` by default), so the
8
+ * timeline always lives next to the artifacts it references — one `.omc`
9
+ * home per project directory, independent of OMC_CONFIG_HOME /
10
+ * OMC_ARTIFACTS_HOME which govern the SEPARATE transport-spill artifact
11
+ * system (`omc artifact get`).
12
+ *
13
+ * Local-machine boundary (deliberate, v1): the journal and the before
14
+ * snapshots inside artifacts stay on the machine that performed the writes;
15
+ * teaching says so. A fresh machine has no rollback history.
16
+ */
17
+ function journalRoot(artifactsDir) {
18
+ return join(resolve(artifactsDir), "..", "journal");
19
+ }
20
+ export function journalFile(environment, artifactsDir) {
21
+ return join(journalRoot(artifactsDir), `${environment}.ndjson`);
22
+ }
23
+ export function appendJournalEntry(artifactsDir, entry) {
24
+ const file = journalFile(entry.environment, artifactsDir);
25
+ mkdirSync(journalRoot(artifactsDir), { recursive: true });
26
+ const seq = countLines(file) + 1;
27
+ const full = { ...entry, seq, ts: new Date().toISOString() };
28
+ appendFileSync(file, `${JSON.stringify(full)}\n`, "utf8");
29
+ return full;
30
+ }
31
+ export function readJournal(environment, artifactsDir) {
32
+ const file = journalFile(environment, artifactsDir);
33
+ if (!existsSync(file))
34
+ return [];
35
+ const entries = [];
36
+ for (const line of readFileSync(file, "utf8").split("\n")) {
37
+ const trimmed = line.trim();
38
+ if (trimmed === "")
39
+ continue;
40
+ try {
41
+ entries.push(JSON.parse(trimmed));
42
+ }
43
+ catch {
44
+ // A torn tail line (crash mid-append) must not hide the timeline.
45
+ }
46
+ }
47
+ return entries;
48
+ }
49
+ /** Resolve a `rollback plan/apply` reference: seq number or artifact id. */
50
+ export function resolveJournalEntry(environment, ref, artifactsDir) {
51
+ const entries = readJournal(environment, artifactsDir);
52
+ const asSeq = Number(ref);
53
+ if (Number.isInteger(asSeq) && asSeq > 0) {
54
+ const bySeq = entries.find((entry) => entry.seq === asSeq);
55
+ if (bySeq)
56
+ return bySeq;
57
+ }
58
+ return entries.find((entry) => entry.artifactId === ref || entry.artifactFile === ref);
59
+ }
60
+ /** Writes recorded AFTER `seq` that touched the same target (clobber warning). */
61
+ export function laterWritesOnTarget(environment, artifactsDir, seq, target) {
62
+ return readJournal(environment, artifactsDir).filter((entry) => entry.seq > seq && entry.target === target);
63
+ }
64
+ function countLines(file) {
65
+ if (!existsSync(file))
66
+ return 0;
67
+ let count = 0;
68
+ for (const line of readFileSync(file, "utf8").split("\n"))
69
+ if (line.trim() !== "")
70
+ count += 1;
71
+ return count;
72
+ }
73
+ //# sourceMappingURL=journal.js.map
package/dist/lanes/app.js CHANGED
@@ -8,6 +8,26 @@ import { wireCall, wireRows } from "../wire.js";
8
8
  import { APP_LANE_CONTRACT } from "./app-contract.js";
9
9
  const APP_LANE = APP_LANE_CONTRACT.lane;
10
10
  const APP_TOOLS = APP_LANE_CONTRACT.tools;
11
+ function appRollbackSpec(toolName) {
12
+ return {
13
+ target: (args) => `app:${String(args.code ?? "")}`,
14
+ read: async (session, args) => {
15
+ const row = (await wireCall(session, { path: ENDPOINTS.applicationGet, query: { appCode: String(args.code) } })).data;
16
+ return row && Object.keys(row).length > 0 ? row : null;
17
+ },
18
+ inverse: ({ before, args }) => {
19
+ const code = String(args.code);
20
+ if (toolName === "app.create")
21
+ return before == null ? { kind: "calls", calls: [{ tool: "app.delete", args: { code } }] } : { kind: "calls", calls: [], notes: ["应用已存在,create 未写入"] };
22
+ if (before == null)
23
+ return { kind: "calls", calls: [] };
24
+ const row = before;
25
+ if (toolName === "app.update")
26
+ return { kind: "calls", calls: [{ tool: "app.update", args: { code, groupId: String(row.groupId) } }] };
27
+ return { kind: "calls", calls: [{ tool: "app.create", args: { code, name: String(row.name ?? row.appName ?? code), ...(row.groupId ? { groupId: row.groupId } : {}) } }] };
28
+ },
29
+ };
30
+ }
11
31
  /**
12
32
  * Portal visibility is group membership: the portal renders NAMED groups, and
13
33
  * an app parked in the fallback group (`code === "all"`, case-insensitive) is
@@ -115,7 +135,7 @@ export function registerAppLane(tools) {
115
135
  note: "应用发布属边界外:建成后需人工在设计器发布并核对该分组,否则门户不可见",
116
136
  },
117
137
  };
118
- });
138
+ }, undefined, undefined, appRollbackSpec("app.create"));
119
139
  writeTool(tools, "app.update", APP_LANE, APP_TOOLS["app.update"].scope, APP_TOOLS["app.update"].description, { code: z.string().min(1), groupId: z.string().min(1) }, async (session, args) => {
120
140
  const record = (await wireCall(session, { path: ENDPOINTS.applicationGet, query: { appCode: args.code } })).data;
121
141
  if (!record || (typeof record === "object" && Object.keys(record).length === 0))
@@ -155,7 +175,7 @@ export function registerAppLane(tools) {
155
175
  portalHidden: portalHiddenByGroup,
156
176
  },
157
177
  };
158
- });
178
+ }, undefined, undefined, appRollbackSpec("app.update"));
159
179
  writeTool(tools, "app.delete", APP_LANE, APP_TOOLS["app.delete"].scope, APP_TOOLS["app.delete"].description, { code: z.string().min(1) }, async (session, args) => {
160
180
  const readApps = async () => wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data);
161
181
  const target = (await readApps()).find((row) => (row.appCode ?? row.code) === args.code);
@@ -169,6 +189,6 @@ export function registerAppLane(tools) {
169
189
  absent = !(await readApps()).some((row) => (row.appCode ?? row.code) === args.code);
170
190
  }
171
191
  return { performed: { errcode: performed.errcode, code: args.code }, readback: { absent } };
172
- });
192
+ }, undefined, undefined, appRollbackSpec("app.delete"));
173
193
  }
174
194
  //# sourceMappingURL=app.js.map
@@ -11,6 +11,29 @@ import { wireCall, wireRows } from "../wire.js";
11
11
  import { BUSINESSRULE_LANE_CONTRACT } from "./businessrule-contract.js";
12
12
  const RULE_LANE = BUSINESSRULE_LANE_CONTRACT.lane;
13
13
  const RULE_TOOLS = BUSINESSRULE_LANE_CONTRACT.tools;
14
+ async function readRuleRollback(session, args) {
15
+ const id = await resolveRuleRowId(session, String(args.schemaCode), String(args.ruleCode));
16
+ if (!id)
17
+ return null;
18
+ const record = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id } })).data;
19
+ if (!record)
20
+ return null;
21
+ const parse = (value) => decodeJsonColumn(value, JSON_COLUMN_NULL_COALESCE);
22
+ return { schemaCode: record.schemaCode, ruleCode: record.code, name: record.name, nodeGraph: { nodes: parse(record.node), routes: parse(record.route), schedulerSetting: parse(record.schedulerSetting) } };
23
+ }
24
+ function businessRuleRollbackSpec(toolName) {
25
+ return {
26
+ target: (args) => `rule:${String(args.schemaCode ?? "")}/${String(args.ruleCode ?? "")}`,
27
+ read: readRuleRollback,
28
+ inverse: ({ before, args, readback }) => {
29
+ const base = { schemaCode: String(args.schemaCode), ruleCode: String(args.ruleCode) };
30
+ if (before == null)
31
+ return { kind: "calls", calls: [{ tool: "rule.delete", args: base }] };
32
+ const row = before;
33
+ return { kind: "calls", calls: [{ tool: "rule.save", args: { ...base, name: String(row.name ?? args.ruleCode), nodeGraph: row.nodeGraph } }], ...(toolName === "rule.node.update" ? { notes: ["节点级回退按整张 nodeGraph 恢复"] } : {}) };
34
+ },
35
+ };
36
+ }
14
37
  /**
15
38
  * Deterministic layout keys the designer/canvas derives and `prepareRuleGraph`
16
39
  * re-injects on save: a node's `x/y/width/height` and a route's `points`. They
@@ -194,7 +217,7 @@ export function registerBusinessRuleLane(tools) {
194
217
  nodeGraph: ruleNodeGraphArg,
195
218
  publish: z.boolean().optional().describe("also publish the new version in this call (businessrule/publish) so the designer sees it; enabling stays with rule.enable — default false keeps the legacy draft-only save"),
196
219
  customTrigger: z.boolean().optional().describe("默认开:自定义 ruleCode + bizRuleType 0(非平台触发码 Create/Update/Delete/GetList/Load/Available/Cancel)即按钮/流程规则,工具在 START 后合成平台标准 CUSTOM_NODE「自定义按钮/流程触发」系统触发节点(nodeCode=ruleCode,live 布局 x558/y233);调用方已写 CUSTOM_NODE 则原样保留;显式 false 退回"),
197
- }, async (session, args) => executeRuleSaveWrite(session, args));
220
+ }, async (session, args) => executeRuleSaveWrite(session, args), undefined, undefined, businessRuleRollbackSpec("rule.save"));
198
221
  // Round 10 (issue 04) — precise modify. `rule.save` replaces the WHOLE nodeGraph,
199
222
  // so changing one branch threshold meant read the whole graph, edit, resend. This
200
223
  // tool reads the current graph, patches ONE node, and reuses the same save seam
@@ -229,6 +252,6 @@ export function registerBusinessRuleLane(tools) {
229
252
  const mergedNode = { ...target, ...args.patch };
230
253
  const mergedGraph = { nodes: nodeList.map((node) => (node.nodeCode === args.nodeCode ? mergedNode : node)), ...(Array.isArray(routes) ? { routes } : {}) };
231
254
  return executeRuleSaveWrite(session, { schemaCode: args.schemaCode, ruleCode: args.ruleCode, name: String(record.name ?? args.ruleCode), nodeGraph: mergedGraph, ...(args.publish === true ? { publish: true } : {}), ...(args.customTrigger === true ? { customTrigger: true } : {}) });
232
- });
255
+ }, undefined, undefined, businessRuleRollbackSpec("rule.node.update"));
233
256
  }
234
257
  //# sourceMappingURL=businessrule.js.map
@@ -562,6 +562,27 @@ async function listDataRules(session, schemaCode, dataRuleType) {
562
562
  query.dataRuleType = String(dataRuleType);
563
563
  return wireRows((await wireCall(session, { path: ENDPOINTS.dataRuleList, query })).data);
564
564
  }
565
+ function dataruleRollbackSpec(toolName) {
566
+ return {
567
+ target: (args) => `datarule:${String(args.schemaCode ?? "")}/${Array.isArray(args.propertyCodes) ? args.propertyCodes.join("+") : String(args.propertyCode ?? "")}`,
568
+ read: async (session, args) => {
569
+ const codes = dataRuleTargetCodes({ propertyCode: args.propertyCode, propertyCodes: args.propertyCodes });
570
+ const rows = await listDataRules(session, String(args.schemaCode), args.dataRuleType);
571
+ return rows.filter((row) => storedTargetCodes(row).some((code) => codes.includes(code))).map(decodeDataRule);
572
+ },
573
+ inverse: ({ before, args }) => {
574
+ const rows = Array.isArray(before) ? before : [];
575
+ const schemaCode = String(args.schemaCode);
576
+ const codes = dataRuleTargetCodes({ propertyCode: args.propertyCode, propertyCodes: args.propertyCodes });
577
+ const locator = { schemaCode, propertyCode: codes.length === 1 ? codes[0] : undefined, propertyCodes: codes.length > 1 ? codes : undefined, dataRuleType: args.dataRuleType };
578
+ if (toolName === "datarule.create")
579
+ return rows.length === 0 ? { kind: "calls", calls: [{ tool: "datarule.delete", args: locator }] } : { kind: "calls", calls: [], notes: ["create 前已有匹配规则,未产生可归因的新行"] };
580
+ if (toolName === "datarule.delete")
581
+ return { kind: "calls", calls: rows.map((row) => ({ tool: "datarule.create", args: { schemaCode, propertyCode: row.propertyCode ?? undefined, propertyCodes: row.propertyCodes, dataRuleType: row.dataRuleType, options: row.options, ...(row.checkType != null ? { checkType: row.checkType } : {}), ...(row.childRule != null ? { childRule: row.childRule } : {}), ...(row.name != null ? { name: row.name } : {}), ...(row.sortKey != null ? { sortKey: row.sortKey } : {}) } })) };
582
+ return { kind: "calls", calls: rows.map((row) => ({ tool: "datarule.update", args: { schemaCode, ...(row.id != null ? { id: row.id } : {}), propertyCode: row.propertyCode ?? undefined, propertyCodes: row.propertyCodes, dataRuleType: row.dataRuleType, options: row.options, ...(row.checkType != null ? { checkType: row.checkType } : {}), ...(row.childRule != null ? { childRule: row.childRule } : {}), ...(row.name != null ? { name: row.name } : {}), ...(row.sortKey != null ? { sortKey: row.sortKey } : {}) } })) };
583
+ },
584
+ };
585
+ }
565
586
  /**
566
587
  * Identity seam: resolve a data-rule row from either its physical `id` or the
567
588
  * business locator `(propertyCode, dataRuleType)`. The latter removes the forced
@@ -738,7 +759,7 @@ export function registerDataRuleLane(tools) {
738
759
  assertDataRuleTarget(args);
739
760
  assertDataRuleOptions(args.dataRuleType, args.options, args.checkType);
740
761
  assertDataRuleCheckType(args.dataRuleType, args.checkType);
741
- });
762
+ }, undefined, dataruleRollbackSpec("datarule.create"));
742
763
  writeTool(tools, "datarule.update", DATARULE_LANE, DATARULE_TOOLS["datarule.update"].scope, DATARULE_TOOLS["datarule.update"].description, {
743
764
  id: z.string().min(1).optional().describe("数据规则记录 id;省略时可改用业务键 schemaCode + propertyCode(+dataRuleType) 定位"),
744
765
  schemaCode: z.string().min(1),
@@ -793,7 +814,7 @@ export function registerDataRuleLane(tools) {
793
814
  };
794
815
  }, (args) => {
795
816
  assertDataRuleOptions(args.dataRuleType, args.options, args.checkType);
796
- });
817
+ }, undefined, dataruleRollbackSpec("datarule.update"));
797
818
  writeTool(tools, "datarule.delete", DATARULE_LANE, DATARULE_TOOLS["datarule.delete"].scope, DATARULE_TOOLS["datarule.delete"].description, {
798
819
  id: z.string().min(1).optional().describe("数据规则记录 id;省略时可改用业务键 schemaCode + propertyCode(+dataRuleType) 定位"),
799
820
  schemaCode: z.string().min(1),
@@ -813,6 +834,6 @@ export function registerDataRuleLane(tools) {
813
834
  performed: { errcode: performed.errcode },
814
835
  readback: { id, existedBefore: before !== undefined, absent: !after.some((row) => row.id === id) },
815
836
  };
816
- });
837
+ }, undefined, undefined, dataruleRollbackSpec("datarule.delete"));
817
838
  }
818
839
  //# sourceMappingURL=datarule.js.map
@@ -10,6 +10,37 @@ const LANE = DICTIONARY_LANE_CONTRACT.lane;
10
10
  const TOOLS = DICTIONARY_LANE_CONTRACT.tools;
11
11
  const dictionaryType = z.union([z.literal(0), z.literal(1)]).describe("KEY_VALUE=0 or TREE=1");
12
12
  const nameI18n = z.record(z.string(), z.unknown()).optional();
13
+ function dictionaryRollbackSpec(toolName) {
14
+ return {
15
+ target: (args) => toolName === "dictionary.save" ? `dictionary:${String(args.code ?? "")}` : `dictionary-class:${String(args.id ?? args.name ?? "")}`,
16
+ read: async (session, args) => {
17
+ if (toolName === "dictionary.save") {
18
+ const header = (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code: String(args.code) } })).data;
19
+ if (!header || Object.keys(header).length === 0)
20
+ return null;
21
+ const records = (await wireCall(session, { path: ENDPOINTS.dictionaryRecordsByCode, query: { code: String(args.code), searchWord: "", page: "1", size: "200" } })).data;
22
+ return { header, records: recordsPage(records).content };
23
+ }
24
+ const listed = (await wireCall(session, { path: ENDPOINTS.dictionaryClasses, query: {} })).data;
25
+ return Array.isArray(listed) ? listed.find((row) => String(row.id ?? "") === String(args.id ?? "") || String(row.name ?? "") === String(args.name ?? "")) ?? null : null;
26
+ },
27
+ inverse: ({ before, args }) => {
28
+ if (toolName === "dictionary.save") {
29
+ if (before == null)
30
+ return { kind: "irreversible", reason: "平台无字典删除端点;可回滚内容:对后续 dictionary.save 的 before 执行 plan/apply" };
31
+ const snapshot = before;
32
+ const h = snapshot.header;
33
+ return { kind: "calls", calls: [{ tool: "dictionary.save", args: { code: h.code, name: h.name, ...(h.classificationId ? { classificationId: h.classificationId } : {}), records: snapshot.records } }] };
34
+ }
35
+ if (before == null)
36
+ return { kind: "calls", calls: [] };
37
+ const row = before;
38
+ if (toolName === "dictionary.class.save")
39
+ return { kind: "calls", calls: [{ tool: "dictionary.class.save", args: { id: row.id, name: row.name, dictionaryIds: row.dictionaryIds, sortKey: row.sortKey } }] };
40
+ return { kind: "calls", calls: [{ tool: "dictionary.class.save", args: { name: String(row.name), dictionaryIds: row.dictionaryIds, sortKey: row.sortKey } }] };
41
+ },
42
+ };
43
+ }
13
44
  const recordSchema = z.object({
14
45
  id: z.string().optional(),
15
46
  code: z.string().min(1),
@@ -172,7 +203,7 @@ export function registerDictionaryLane(tools) {
172
203
  // failure must not turn a landed write into a failure.
173
204
  }
174
205
  return { performed: { errcode: performed.errcode }, readback: { present: Boolean(after && Object.keys(after).length > 0), dictionary: dictionaryView(after), records: recordsReadback } };
175
- });
206
+ }, undefined, undefined, dictionaryRollbackSpec("dictionary.save"));
176
207
  writeTool(tools, "dictionary.status", LANE, TOOLS["dictionary.status"].scope, TOOLS["dictionary.status"].description, {
177
208
  target: z.enum(["dictionary", "record"]), id: z.string().min(1), status: z.boolean(),
178
209
  }, async (session, args) => {
@@ -222,7 +253,7 @@ export function registerDictionaryLane(tools) {
222
253
  const classes = Array.isArray(listed) ? listed : [];
223
254
  const saved = classes.find((entry) => entry.name === args.name && (args.id === undefined || entry.id === args.id)) ?? classes.find((entry) => entry.name === args.name);
224
255
  return { performed: { errcode: performed.errcode }, readback: { present: saved !== undefined, id: saved?.id, name: args.name, dictionaryIds: args.dictionaryIds ?? [] } };
225
- });
256
+ }, undefined, undefined, dictionaryRollbackSpec("dictionary.class.save"));
226
257
  writeTool(tools, "dictionary.class.delete", LANE, TOOLS["dictionary.class.delete"].scope, TOOLS["dictionary.class.delete"].description, {
227
258
  ids: z.array(z.string().min(1)).min(1),
228
259
  }, async (session, args) => {
@@ -230,6 +261,6 @@ export function registerDictionaryLane(tools) {
230
261
  const body = args.ids.length === 1 ? { id: args.ids[0] } : { ids: args.ids };
231
262
  const performed = await wireCall(session, { path, method: "POST", body });
232
263
  return { performed: { errcode: performed.errcode, ids: args.ids }, readback: { absent: true, ids: args.ids } };
233
- });
264
+ }, undefined, undefined, dictionaryRollbackSpec("dictionary.class.delete"));
234
265
  }
235
266
  //# sourceMappingURL=dictionary.js.map
@@ -180,7 +180,7 @@ export const FIELD_LANE_CONTRACT = Object.freeze({
180
180
  scope: "create publish readback",
181
181
  description: describeTool("Update an existing field (bizproperty/update) — merges `options` (and optional `choices`/`relativeCode`) onto the current draft/published record.", [
182
182
  "**不切换 propertyType**:field.update 传 propertyType 会被静默忽略(errcode 0 但类型不变,r6 实证 2026-10-10)——已发布字段改类型只能 field.delete + field.create 重建(含关联 mappings 的同类型对齐场景:人员单选只能映射给人员单选)。choices 只对文本单选族(RADIO/DROPDOWN_BOX 等)合法,传给其它类型会被出门校验拒。",
183
- "Use it to add relation `mappings` (source/target carry-through) or change field options.",
183
+ "Use it to add relation `mappings` (source/target carry-through) or change field options. **关联填充映射的事实源是字段行 `options.mappings`**(真机 2026-10-14 中冶实证:改表单控件属性里的 mappings 会被平台按字段重算覆盖、回读不生效)。改映射的正确链:`field.update` 按完整 options 提交(只改 mappings,其余键 queryCode/displayField/schemaCode/conditions… 原值保留)→ `field.publish` → 已发布表单重派生一次:`form.draft`(**rebuild:false**,保留现有布局与已挂在线JS)重存 → `form.publish` → 回读核对。**不要用 field.update 的 cascadeRepublish 刷已带自定义布局/在线JS 的表单**——它会 rebuild 脚手架,可能动到现有自定义布局(rebuild:false 手动链才安全)。",
184
184
  "A relation's display field is the field-row top-level `relativePropertyCode` (options.displayField is mirrored but not what the runtime reads); the tool rewrites it when you pass a new displayField.",
185
185
  "表单条件(`options.requiredFormula`(必填)/`readonlyCondition`(只读)/`displayFormula`(显示))会**自动配对**一条 `bizdatarule` type3/4/5 行:门户**运行时**读字段 options,表单设计器**设计态** Set/Unset 读 `bizdatarule/list`——只写 options 会让设计器显示 Unset。readback.formConditionSync 报 created/updated/deleted/skipped;清空条件传空串(如 requiredFormula:\"\")会同时删规则行;字段未发布时只写 options 并在 skipped 说明原因。",
186
186
  "Re-publish with field.publish for the change to take effect — or pass `cascadeRepublish:true` for the one-call chain: field.update → field.publish → form.draft(rebuild) → form.publish(force). The cascade re-scaffolds the default form from the published fields (non-structural control options are preserved); there is no cross-endpoint transaction, so a mid-chain failure is reported honestly as partial (status:partial) with steps/failedStep/envelope (the completed prefix stays).",
@@ -15,6 +15,32 @@ import { FIELD_LANE_CONTRACT } from "./field-contract.js";
15
15
  import { mapBounded } from "../support.js";
16
16
  const FIELD_LANE = FIELD_LANE_CONTRACT.lane;
17
17
  const FIELD_TOOLS = FIELD_LANE_CONTRACT.tools;
18
+ async function readFieldRollback(session, args) {
19
+ for (const published of [false, true]) {
20
+ const row = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: String(args.schemaCode), isPublish: String(published) } })).data).find((entry) => String(entry.code ?? entry.propertyCode) === String(args.code));
21
+ if (row)
22
+ return row;
23
+ }
24
+ return null;
25
+ }
26
+ function fieldRollbackSpec(toolName) {
27
+ return {
28
+ target: (args) => `field:${String(args.schemaCode ?? "")}/${String(args.code ?? "")}`,
29
+ read: readFieldRollback,
30
+ inverse: ({ before, args }) => {
31
+ const schemaCode = String(args.schemaCode), code = String(args.code);
32
+ if (toolName === "field.create")
33
+ return before == null ? { kind: "calls", calls: [{ tool: "field.delete", args: { schemaCode, code } }] } : { kind: "calls", calls: [], notes: ["字段已存在,create 未写入"] };
34
+ if (before == null)
35
+ return { kind: "calls", calls: [] };
36
+ const row = before;
37
+ const options = row.options !== undefined ? decodeJsonColumn(row.options, JSON_COLUMN_PASSTHROUGH) : undefined;
38
+ if (toolName === "field.delete")
39
+ return { kind: "calls", calls: [{ tool: "field.create", args: { schemaCode, code, name: String(row.name ?? code), propertyType: String(row.propertyType ?? row.type), ...(options !== undefined ? { options } : {}), ...(row.choices !== undefined ? { choices: row.choices } : {}), ...(row.relativeCode !== undefined ? { relativeCode: row.relativeCode } : {}), ...(row.subFields !== undefined ? { subFields: row.subFields } : {}) } }] };
40
+ return { kind: "calls", calls: [{ tool: "field.update", args: { schemaCode, code, ...(row.name !== undefined ? { name: row.name } : {}), ...(row.name_i18n !== undefined ? { name_i18n: row.name_i18n } : {}), ...(options !== undefined ? { options } : {}), ...(row.choices !== undefined ? { choices: row.choices } : {}), ...(row.relativeCode !== undefined ? { relativeCode: row.relativeCode } : {}), ...(row.subFields !== undefined ? { subFields: row.subFields } : {}), ...(row.propertyEmpty !== undefined ? { propertyEmpty: row.propertyEmpty } : {}) } }] };
41
+ },
42
+ };
43
+ }
18
44
  /** Parse a raw options JSON string for readback; policy: non-strings and unparseable strings pass through verbatim. */
19
45
  function parseFieldOptionsForReadback(options) {
20
46
  return decodeJsonColumn(options, JSON_COLUMN_PASSTHROUGH);
@@ -210,7 +236,7 @@ export function registerFieldLane(tools) {
210
236
  performed: { errcode: performed.errcode },
211
237
  readback: { draftFieldPresent: rows.some((row) => (row.code ?? row.propertyCode) === args.code) },
212
238
  };
213
- });
239
+ }, undefined, undefined, fieldRollbackSpec("field.create"));
214
240
  writeTool(tools, "field.update", FIELD_LANE, FIELD_TOOLS["field.update"].scope, FIELD_TOOLS["field.update"].description, {
215
241
  schemaCode: z.string().min(1),
216
242
  code: z.string().min(1),
@@ -230,7 +256,7 @@ export function registerFieldLane(tools) {
230
256
  if (args.cascadeRepublish !== true)
231
257
  return outcome;
232
258
  return runFieldUpdateCascade(session, args, outcome);
233
- }, (args) => assertNameI18nArg(args.name_i18n));
259
+ }, (args) => assertNameI18nArg(args.name_i18n), undefined, fieldRollbackSpec("field.update"));
234
260
  writeTool(tools, "field.publish", FIELD_LANE, FIELD_TOOLS["field.publish"].scope, FIELD_TOOLS["field.publish"].description, {
235
261
  schemaCode: z.string().min(1),
236
262
  code: z.string().min(1).optional().describe("optional single field to verify on the published face"),
@@ -246,7 +272,7 @@ export function registerFieldLane(tools) {
246
272
  const published = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
247
273
  const gone = (value) => !wireRows(value).some((row) => (row.code ?? row.propertyCode) === args.code);
248
274
  return { performed: { errcode: performed.errcode }, readback: { absentDraft: gone(draft.data), absentPublished: gone(published.data) } };
249
- });
275
+ }, undefined, undefined, fieldRollbackSpec("field.delete"));
250
276
  }
251
277
  /** The proven field.update write body — shared by the write tool and the cascade path. */
252
278
  async function runFieldUpdate(session, args) {
@@ -18,7 +18,7 @@ export const FORM_WIRE = Object.freeze({
18
18
  actionsColumns: "ticket 100: draftActionsJson/publishedActionsJson are REAL bizsheet columns (BizForm.java:95-100 @SerializableField; BizFormModel.java:59-62 serialized by bizsheet/get) — the designer writes draftActionsJson on save and both faces on publish (frontend-dist/admin/js/4280.cfa75764.js offsets 193538/185347); form.get full:true surfaces them; publish promotes draftActionsJson → publishedActionsJson",
19
19
  });
20
20
  const FORM_GET_DESCRIPTION = '读取默认表单(bizsheet/get)的发布状态与控件清单(紧凑默认)。**发布态以返回的 `published` / `existDraft` 布尔为准**(`published:true`=已发布;`published:false,existDraft:true`=只有草稿,**未发布**)——不要用 `publishedControlCodes` 是否非空来推断(草稿字段会让它非空,误判为已发布)。**每个模型交付前其默认表单必须 `published:true`**:用 `configure_form` 或 `form.draft(rebuild:true)`+`form.publish` 发布,并逐模型 `form.get` 核对。传 `controls:["code",…]` 只取指定控件的 options(如关联控件的 mappings);传 `full:true` 才返回 draft/published 的完整 view/attributes/html/actions JSON(actions 列为设计器保存/发布的表单级按钮动作)。体积大,仅在需要整体布局时用。';
21
- const FORM_DRAFT_DESCRIPTION = "Save the default-form DRAFT (bizsheet/update). Reads the current sheet, merges the caller's explicit draftViewJson/draftAttributesJson (or preserves the existing draft); when both are absent on a fresh default form, composes the designer-standard scaffold from the model's published fields. A redundant field (relation-mapping carry-through target) always stays bound; with no workflow it is hidden in the design (options.visible=false), with a workflow it stays visible for per-node propertyPermissions. Pass `draftHtmlJson` to thread custom online-script HTML through. Empty-layout guard: explicitly passing \"[]\"/\"{}\" view/attributes is refused (form-empty-layout) — the platform designer's Save is NOT lossless and clears drafts to exactly that shape; escape with allowEmptyLayout:true, and recover cleared drafts from the form version history (/api/api/app/bizsheet/history/list).";
21
+ const FORM_DRAFT_DESCRIPTION = "Save the default-form DRAFT (bizsheet/update). Reads the current sheet, merges the caller's explicit draftViewJson/draftAttributesJson (or preserves the existing draft); when both are absent on a fresh default form, composes the designer-standard scaffold from the model's published fields. A redundant field (relation-mapping carry-through target) always stays bound; with no workflow it is hidden in the design (options.visible=false), with a workflow it stays visible for per-node propertyPermissions. Pass `draftHtmlJson` to thread custom online-script HTML through. Empty-layout guard: explicitly passing \"[]\"/\"{}\" view/attributes is refused (form-empty-layout) — the platform designer's Save is NOT lossless and clears drafts to exactly that shape; escape with allowEmptyLayout:true, and recover cleared drafts from the form version history (/api/api/app/bizsheet/history/list). **表单控件属性(含关联填充 mappings)从字段行派生**:改关联映射的事实源在字段 options.mappings(field.update → field.publish),直接改这里的控件映射会被平台按字段重算覆盖(真机 2026-10-14 实证);字段发布后如需刷新已发布表单的控件映射,用本工具 rebuild:false 重存(保留现有布局与在线JS)再 form.publish。";
22
22
  const FORM_PUBLISH_DESCRIPTION = "Publish the default form (bizsheet/publish, proven shape: draft*Json promoted to published*Json verbatim) with published-face readback. Degrade guard: refuses to promote a draft carrying FEWER controls than the published face (form-publish-degrade) — the designer's Save clears drafts non-losslessly; recover via the form version history (/api/api/app/bizsheet/history/list) and rewrite the draft first, or pass allowDegrade:true to force the reduction. rebuild:true is the one-call refresh: discard the existing draft, re-scaffold from the published fields (non-structural control options preserved), then force-promote — use it after a field shape change.";
23
23
  const CONFIGURE_FORM_DESCRIPTION = "High-level: bind fields into the default form and publish it (form.draft → form.publish), subject to the write permission gate. Delegates to the proven form.draft scaffold + form.publish paths so the real empty-form layout (and any caller-supplied draftHtmlJson) is persisted and promoted — never bypasses with a shortcut body. `sheetCode` is optional: omitted means the model's default sheet (sheetCode = schemaCode).";
24
24
  export const FORM_LANE_CONTRACT = Object.freeze({
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
- import { executeFormDraftWrite, executeFormPublishWrite, validateHtmlRender } from "../form-support.js";
3
+ import { executeFormDraftWrite, executeFormPublishWrite, readFormOrNull, refuseEmptyCallerLayout, validateHtmlRender } from "../form-support.js";
4
4
  import { capabilityArg, environmentArg } from "../args.js";
5
5
  import { governedRead } from "../read.js";
6
6
  import { writeTool } from "../write.js";
@@ -127,7 +127,24 @@ export function registerFormLane(tools) {
127
127
  fieldCodes: z.array(z.string().min(1)).optional().describe("脚手架绑定的已发布字段(按此顺序);由 configure_form/intent 显式传入"),
128
128
  rebuild: z.boolean().optional().describe("true 时丢弃现有草稿布局,按已发布字段重新生成(新增字段/子表要用它;默认保留现有草稿)"),
129
129
  allowEmptyLayout: z.boolean().optional().describe("显式放行空布局(\"[]\"/\"{}\")写入;默认拒绝(form-empty-layout),防设计器 Save 清空后照写丢控件"),
130
- }, async (session, args) => executeFormDraftWrite(session, { ...args, sheetCode: resolveSheetCode(args.schemaCode, args.sheetCode) }));
130
+ }, async (session, args) => executeFormDraftWrite(session, { ...args, sheetCode: resolveSheetCode(args.schemaCode, args.sheetCode) }), (args) => {
131
+ // Zero-wire refusal (ticket 74): fires in semanticGate BEFORE the
132
+ // governed pipeline — including the rollback before-capture read.
133
+ refuseEmptyCallerLayout({ ...args, sheetCode: resolveSheetCode(args.schemaCode, args.sheetCode) });
134
+ }, undefined, {
135
+ target: (args) => `form:${String(args.schemaCode ?? "")}/${String(args.sheetCode ?? args.schemaCode ?? "")}`,
136
+ read: (session, args) => readFormOrNull(session, String(args.schemaCode), resolveSheetCode(String(args.schemaCode), args.sheetCode)),
137
+ inverse: ({ before, args }) => {
138
+ if (before == null)
139
+ return { kind: "irreversible", reason: "表单写入前不存在;平台没有删除表单端点,无法撤销创建" };
140
+ const row = before;
141
+ const restored = { schemaCode: String(args.schemaCode), sheetCode: resolveSheetCode(String(args.schemaCode), args.sheetCode) };
142
+ for (const key of ["draftViewJson", "draftAttributesJson", "draftHtmlJson"])
143
+ if (typeof row[key] === "string")
144
+ restored[key] = row[key];
145
+ return { kind: "calls", calls: [{ tool: "form.draft", args: restored }] };
146
+ },
147
+ });
131
148
  writeTool(tools, "form.publish", FORM_LANE, FORM_TOOLS["form.publish"].scope, FORM_TOOLS["form.publish"].description, {
132
149
  schemaCode: z.string().min(1),
133
150
  sheetCode: z.string().min(1).optional().describe("表单槽位码;省略时默认 = schemaCode(模型码即默认表单槽位)"),