@stalfh233/omc-cli 0.4.36 → 0.4.38

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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/bench/call-face-eval.js +1 -1
  3. package/dist/bench/discovery-cost-eval.js +1 -1
  4. package/dist/bench/driver.js +1 -1
  5. package/dist/bench/intent-face-eval.js +1 -1
  6. package/dist/capability-shape.js +2 -153
  7. package/dist/cli.js +42 -578
  8. package/dist/contract-consistency.js +5 -1
  9. package/dist/execution-metadata.js +24 -1160
  10. package/dist/field-families.js +2 -22
  11. package/dist/intent/plan-apply.js +587 -0
  12. package/dist/json-column.js +2 -67
  13. package/dist/lanes/businessrule-contract.js +4 -3
  14. package/dist/lanes/list-view-shapes.js +13 -98
  15. package/dist/lanes/model-contract.js +2 -2
  16. package/dist/lanes/rule-graph.js +2 -2
  17. package/dist/lanes/runtime-data-contract.js +6 -83
  18. package/dist/recipes/b2-registration.js +87 -0
  19. package/dist/recipes/businessrule.js +269 -0
  20. package/dist/recipes/datarule.js +215 -0
  21. package/dist/recipes/dictionary.js +42 -0
  22. package/dist/recipes/field.js +230 -0
  23. package/dist/recipes/form.js +19 -0
  24. package/dist/recipes/list-view.js +31 -0
  25. package/dist/recipes/model.js +34 -0
  26. package/dist/recipes/online-js.js +32 -0
  27. package/dist/recipes/runtime-data.js +113 -0
  28. package/dist/recipes/shared.js +8 -0
  29. package/dist/recipes/workflow.js +119 -0
  30. package/dist/tool-types.js +2 -7
  31. package/dist/zip.js +2 -155
  32. package/package.json +3 -2
  33. package/dist/bench/fake-http-server.js +0 -65
  34. package/dist/fake/app.js +0 -45
  35. package/dist/fake/b2-registration.js +0 -620
  36. package/dist/fake/businessrule.js +0 -242
  37. package/dist/fake/datarule.js +0 -72
  38. package/dist/fake/dictionary.js +0 -108
  39. package/dist/fake/environment.js +0 -30
  40. package/dist/fake/field.js +0 -167
  41. package/dist/fake/form.js +0 -101
  42. package/dist/fake/index.js +0 -121
  43. package/dist/fake/list-view.js +0 -289
  44. package/dist/fake/model.js +0 -136
  45. package/dist/fake/online-js.js +0 -18
  46. package/dist/fake/report.js +0 -253
  47. package/dist/fake/routes.js +0 -47
  48. package/dist/fake/rule-lifecycle.js +0 -37
  49. package/dist/fake/runtime-data.js +0 -367
  50. package/dist/fake/state.js +0 -67
  51. package/dist/fake/workflow.js +0 -364
@@ -1,3 +1,5 @@
1
+ export { relationTargetOf } from "omc-core/relation-shape";
2
+ import { relationOptions } from "omc-core/relation-shape";
1
3
  import { z } from "zod";
2
4
  import { closestMatch } from "./closest-match.js";
3
5
  import { ToolRefusalError } from "./tool-types.js";
@@ -398,28 +400,6 @@ export function fieldControlOptions(type, name, relativeCode, relationMappings,
398
400
  default: return merged;
399
401
  }
400
402
  }
401
- /** relation options face decode; policy: string-or-object accepted, garbage → undefined. */
402
- function relationOptions(options) {
403
- return decodeJsonObjectColumn(options, { invalid: "undefined", array: "accept", object: "accept" });
404
- }
405
- /**
406
- * The relation target model of a bizproperty row. Relation families
407
- * (WORK_SHEET / MULT_WORK_SHEET) carry the target model code both as the
408
- * row-level `relativeCode` and as the `schemaCode` key inside the options JSON
409
- * (field.create writes both). RELEVANCE_DATA(16) is deliberately excluded: it
410
- * references another *field* (relevanceField/referenceField), not a model, so
411
- * it adds no inbound model reference of its own — the local WORK_SHEET field it
412
- * resolves through already carries that reference. Either face counts as an
413
- * inbound reference for the model.delete precheck (ticket 23: the server's
414
- * deleteFunctions dispatcher refuses asynchronously with
415
- * "xxx被模型 yyy 引用,不能删除" — the tool surfaces that check at call time).
416
- */
417
- export function relationTargetOf(row) {
418
- if (typeof row.relativeCode === "string" && row.relativeCode.trim())
419
- return row.relativeCode.trim();
420
- const schemaCode = relationOptions(row.options)?.schemaCode;
421
- return typeof schemaCode === "string" && schemaCode.trim() ? schemaCode.trim() : undefined;
422
- }
423
403
  export function relationDisplayField(options) {
424
404
  const displayField = relationOptions(options)?.displayField;
425
405
  return typeof displayField === "string" && displayField.trim() ? displayField.trim() : undefined;
@@ -0,0 +1,587 @@
1
+ /**
2
+ * intent 生命周期编排层(A 重构,自 cli.ts 迁入,逻辑逐字保留):
3
+ * plan(不可变计划)/ readback(终态等价断言)/ apply(漂移门+写锁+apply_changes 执行+最终读回)。
4
+ * CLI 只保留参数解析与 transport 适配;emit/invalid/sleep 经 ports 注入以便单测(默认行为不变)。
5
+ */
6
+ import { writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { createArtifact, readArtifact } from "../artifacts.js";
9
+ import { artifactRoot } from "../command-transport.js";
10
+ import { makeToolContext } from "../context.js";
11
+ import { SessionManager } from "../session-manager.js";
12
+ import { ConfigStore, defaultPaths } from "../config-store.js";
13
+ import { buildToolRegistry } from "../tools.js";
14
+ import { acquireWriteLock } from "../write-lock.js";
15
+ import { wireCall, WireError } from "../wire.js";
16
+ import { ENDPOINTS } from "../endpoints.js";
17
+ import { extractBizObjectPage } from "../lanes/runtime-data.js";
18
+ import { ToolRefusalError, ToolInputError, parseToolInput } from "../tool-types.js";
19
+ import { jsonBytes, sha256, writeArtifact } from "../support.js";
20
+ import { INTENT_COMPILER_VERSION, intentConfigRoot, readIntentFile } from "./cli.js";
21
+ import { validateRuleNodeGraph, PLATFORM_TRIGGER_RULE_CODES, walkConditionLeaves } from "../lanes/rule-graph.js";
22
+ import { expectedRuleGraph } from "./capabilities/businessrule.js";
23
+ import { readBaseline, baselineFingerprintOf } from "./baseline.js";
24
+ import { compileIntent } from "./compiler.js";
25
+ import { buildPlan, verifyPlan } from "./plan-file.js";
26
+ import { decodeBaseline, compareWithLive } from "./readback.js";
27
+ const INTENT_PLAN_INLINE_CEILING_BYTES = 16384;
28
+ /**
29
+ * Warning-severity findings the declarative face must not swallow. The rule
30
+ * dry-lint computes warnings (e.g. `rule-graph-cross-model-unverified`: a
31
+ * cross-model UPDATE saves+publishes but was observed to write nothing at
32
+ * runtime — bench50/52/53/55); `validateRuleNodeGraph` is the single source, so
33
+ * this reuses it rather than re-deriving semantics.
34
+ */
35
+ export function intentWarnings(intent) {
36
+ const warnings = [];
37
+ for (const model of intent.models) {
38
+ // Field-level data rules of type 3/4/5 (拼接/必填/只读) are PORTAL-FACING, but
39
+ // the platform stores them as bizdatarule rows and mirrors them only into the
40
+ // form control's `historyOpt` — while the runtime portal reads the control's
41
+ // `options`. So a datarule-only required/readonly renders/stays inert in the
42
+ // portal (live 2026-09-28: bench62 datarule type4 on order_note, published
43
+ // control had no requiredFormula; the live portal did not require it). The
44
+ // portal-facing condition must be declared in `fields[].options`.
45
+ for (const datarule of model.datarules ?? []) {
46
+ if ([3, 4, 5].includes(datarule.dataRuleType))
47
+ warnings.push({
48
+ resource: `datarule:${model.code}.${datarule.propertyCode}`,
49
+ code: "datarule-not-portal-facing",
50
+ message: `字段级数据规则 type ${datarule.dataRuleType}(${datarule.dataRuleType === 4 ? "条件必填" : datarule.dataRuleType === 5 ? "条件只读" : "文本拼接"})不驱动门户表单控件:平台把它存成 bizdatarule、只镜像进控件 historyOpt,而运行时门户读控件 options(真机复核:规则已存但发布控件无该字段、门户不拦)。要让门户生效,把条件写到字段的 options 里(fields[].options.requiredFormula / readonlyCondition / shortTextStitch)——这条路径已真机验证可穿透到发布控件;datarule 只留给服务端字段级引擎(校验/计算)。`,
51
+ });
52
+ // Dual-write advisory (dogfood R1): when the SAME field already declares
53
+ // the portal-facing condition in `fields[].options`, the datarule row is
54
+ // redundant. Surface it rather than silently writing both.
55
+ const field = model.fields.find((candidate) => candidate.code === datarule.propertyCode);
56
+ const fieldOptions = field?.options ?? {};
57
+ const duplicateKey = datarule.dataRuleType === 4 ? "requiredFormula" : datarule.dataRuleType === 5 ? "readonlyCondition" : undefined;
58
+ if (duplicateKey !== undefined && fieldOptions[duplicateKey] !== undefined)
59
+ warnings.push({
60
+ resource: `datarule:${model.code}.${datarule.propertyCode}`,
61
+ code: "datarule-duplicates-field-options",
62
+ message: `字段 ${model.code}.${datarule.propertyCode} 同时在 fields[].options.${duplicateKey} 和 datarules[](type ${datarule.dataRuleType})声明了同一条件——门户只读 options,datarule 那条是冗余(服务端字段级引擎用)。若要门户生效保留 options 即可;datarule 仅在需要服务端强制时才必须保留。`,
63
+ });
64
+ }
65
+ for (const rule of model.rules ?? []) {
66
+ // Preset trigger rules are platform-managed: their `name` is a platform
67
+ // default (数据新增/数据更新/…). `rule.save` keeps the stored name for these
68
+ // codes, so a declared `name` is ignored — say so rather than silently
69
+ // dropping it (a caller who meant to rename would otherwise be confused).
70
+ if (PLATFORM_TRIGGER_RULE_CODES.has(rule.code) && rule.nameImplicit !== true)
71
+ warnings.push({
72
+ resource: `rule:${model.code}.${rule.code}`,
73
+ code: "rule-preset-name-ignored",
74
+ message: `规则 ${rule.code} 是平台预置触发规则,其名称由平台维护(如 Create=数据新增);声明的 name "${rule.name}" 不会生效(只有自定义规则的名称与节点名可改)——预置行可直接省略 name 以消除本警告;要改显示名请改 nodeGraph/actions 中节点的 nodeName。`,
75
+ });
76
+ let graph;
77
+ try {
78
+ graph = expectedRuleGraph(rule, model.code);
79
+ }
80
+ catch {
81
+ continue; // shape errors are surfaced by validate/plan already
82
+ }
83
+ // Round 16 (bench66): `filter` LOCATES the rows to update, so a same-model
84
+ // action whose filter uses only non-unique fields updates EVERY matching
85
+ // row, not just the submitted one. bench66 produced status=中额 for a
86
+ // 120000 order because the ladder's overlapping amount-filters each matched
87
+ // the one row and the last one won — and a real table would have had its
88
+ // other rows clobbered. Keep bulk expressible (warn, don't gate) but name
89
+ // both fixes: locate self with `filter: [{field:id, from:id}]`, or gate the
90
+ // action with `when:` (an execution-gate `dataCondition`).
91
+ for (const node of graph.nodes ?? []) {
92
+ if (String(node.nodeType ?? "") !== "UPDATE")
93
+ continue;
94
+ const target = String(node.targetObjectCode ?? "");
95
+ const sameModel = target === model.code || target.startsWith(`${model.code}.`);
96
+ if (!sameModel)
97
+ continue;
98
+ const leaves = [];
99
+ walkConditionLeaves(node.filterCondition, (leaf) => leaves.push(leaf));
100
+ if (leaves.length === 0)
101
+ continue; // no filter = default self-identity, safe
102
+ if (leaves.some((leaf) => String(leaf.targetSchemaDataItem) === "id"))
103
+ continue;
104
+ warnings.push({
105
+ resource: `rule:${model.code}.${rule.code}`,
106
+ code: "rule-locator-not-unique",
107
+ message: `规则 ${rule.code} 的 UPDATE 节点定位条件(filterCondition)未用唯一键 id:filter 是「定位要更新哪些行」,用非唯一字段(如金额/状态)会命中并更新**多条**记录(真机 bench66:金额分级误配导致多行互相覆盖)。只更新当前这条用 filter: [{field: id, from: id}](省略 filter 时同模型动作已默认按自身 id 定位);「输入数据满足条件才执行该节点」是执行门,用 when: [{field: <字段>, gt: <值>}](编译到 dataCondition),与 filter 是两码事。确属批量更新可忽略本告警。`,
108
+ });
109
+ }
110
+ for (const finding of validateRuleNodeGraph(graph, rule.code, model.code)) {
111
+ if (finding.severity === "warning")
112
+ warnings.push({ resource: `rule:${model.code}.${rule.code}`, code: finding.code, message: finding.message });
113
+ }
114
+ }
115
+ }
116
+ return warnings;
117
+ }
118
+ /**
119
+ * Spec 107 readback core: decode the authoritative live baseline back into
120
+ * intent shape and compare it to the source intent. Shared by `intent readback`
121
+ * (equivalent:false is a legitimate not-yet-applied state, exit 0) and, since
122
+ * ticket 123, by `intent apply` as the final confirmation step, where a
123
+ * mismatch must not be reported as plain success.
124
+ */
125
+ export async function verifyIntentLive(session, intent) {
126
+ const baseline = await readBaseline(session, intent);
127
+ const live = decodeBaseline(baseline, intent);
128
+ const report = compareWithLive(intent, live);
129
+ return {
130
+ environment: session.environmentName,
131
+ environmentFingerprint: session.environmentFingerprint(),
132
+ baselineFingerprint: baselineFingerprintOf(baseline),
133
+ equivalent: report.equivalent,
134
+ differences: report.differences,
135
+ ignoredOutsideIntent: report.ignoredOutsideIntent,
136
+ live,
137
+ };
138
+ }
139
+ /**
140
+ * Route the decoded `live` blob to an artifact once it exceeds the inline
141
+ * ceiling, keeping only the decision fields (equivalent / differences /
142
+ * counts). Real requirements decode to tens of KB: round 9 (sandbox 43) an
143
+ * `intent readback` was 62 KB × ~10 calls because it inlined the full live
144
+ * state, and the agent redirected each to a file to avoid the context cost.
145
+ * Same diet as governedRead's full ceiling (src/read.ts) and the same
146
+ * reachability guarantee: the blob stays fully available via the artifact.
147
+ */
148
+ const INTENT_LIVE_INLINE_CEILING_BYTES = 8192;
149
+ /**
150
+ * Context-cost (round 15): `ignoredOutsideIntent` lists every resource outside
151
+ * the intent (ownership-ignored, never a difference). A real model carries ~40
152
+ * system fields, so a 3-model intent reported 212 entries = ~5.7 KB of pure
153
+ * noise on every readback — twice per task. Inline a bounded sample plus the
154
+ * true count; the list is informational, so nothing decision-making is lost.
155
+ */
156
+ const INTENT_IGNORED_INLINE_LIMIT = 20;
157
+ function capIgnoredOutsideIntent(verification) {
158
+ const ignored = verification.ignoredOutsideIntent;
159
+ if (!Array.isArray(ignored) || ignored.length <= INTENT_IGNORED_INLINE_LIMIT)
160
+ return {};
161
+ return {
162
+ ignoredOutsideIntent: ignored.slice(0, INTENT_IGNORED_INLINE_LIMIT),
163
+ ignoredOutsideIntentCount: ignored.length,
164
+ ignoredOutsideIntentHint: `意图外资源共 ${ignored.length} 项(按所有权忽略,不是差异);此处只内联前 ${INTENT_IGNORED_INLINE_LIMIT} 项`,
165
+ };
166
+ }
167
+ export function dietIntentLive(verification, context) {
168
+ const capped = capIgnoredOutsideIntent(verification);
169
+ const live = verification.live;
170
+ if (live === undefined)
171
+ return { ...verification, ...capped };
172
+ const bytes = jsonBytes(live);
173
+ if (bytes <= INTENT_LIVE_INLINE_CEILING_BYTES)
174
+ return { ...verification, ...capped };
175
+ const { live: _omitted, ...rest } = verification;
176
+ const ref = writeArtifact(context, `intent-live-${sha256(JSON.stringify(live)).slice(0, 16)}.json`, live);
177
+ return {
178
+ ...rest,
179
+ ...capped,
180
+ liveOmitted: true,
181
+ liveBytes: bytes,
182
+ liveArtifactHint: `权威 live 状态 ${bytes}B 超过内联上限 ${INTENT_LIVE_INLINE_CEILING_BYTES}B,已外置;上面的 equivalent/differences 已是决策所需,需要原生状态用 \`omc artifact get <detailArtifactId>\`(可 --jq <path>)定向取回`,
183
+ detailFile: ref.detailFile,
184
+ detailSha256: ref.detailSha256,
185
+ };
186
+ }
187
+ /** Ticket 118 — intent plan:只读 baseline → 编译 → 不可变 plan。调用方(CLI 包装)先完成 intent 文件读取与校验。 */
188
+ export async function planIntent(intent, environment, planOut) {
189
+ // Validate the optional plan output path BEFORE any environment contact, so a
190
+ // bad path is a clean invalid-input (exit 10), never a wire attempt.
191
+ // planOut 合法性(须在配置根内)由 CLI 包装层先行校验——transport 关注点不入编排层。
192
+ const outPath = planOut;
193
+ let result;
194
+ try {
195
+ const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
196
+ const session = await context.sessions.session(environment);
197
+ const baseline = await readBaseline(session, intent);
198
+ const compiled = compileIntent(intent, baseline);
199
+ const plan = buildPlan({
200
+ environment: session.environmentName,
201
+ environmentFingerprint: session.environmentFingerprint(),
202
+ compiler: INTENT_COMPILER_VERSION,
203
+ baseline,
204
+ intent,
205
+ compiled,
206
+ });
207
+ const planJson = JSON.stringify(plan, null, 2) + "\n";
208
+ const artifact = createArtifact(artifactRoot(), planJson);
209
+ const warnings = intentWarnings(intent);
210
+ // r39: a destructive plan that deletes a model carrying runtime data got
211
+ // ZERO warning — the approval object must surface the blast radius. One
212
+ // cheap total-only query per declared-absent model (never blocks the plan:
213
+ // an unreadable runtime face just means no count to show).
214
+ for (const model of intent.models) {
215
+ if (model.absent !== true)
216
+ continue;
217
+ try {
218
+ const query = await wireCall(session, { path: ENDPOINTS.runtimeQueryList, method: "POST", body: { schemaCode: model.code, queryCode: model.code, filters: [], mobile: false, page: 0, size: 1, queryVersion: 1, showTotal: true } });
219
+ const total = extractBizObjectPage(query.data).total;
220
+ if (total > 0)
221
+ warnings.push({ resource: `model:${model.code}`, code: "destructive-with-data", message: `模型 ${model.code} 含 ${total} 条运行数据,absent:true 删除将一并销毁(不可恢复)——确认后再 apply` });
222
+ }
223
+ catch {
224
+ // 运行面不可读(模型/视图未就绪等)= 无法计数,不阻塞 plan
225
+ }
226
+ }
227
+ const toolCounts = new Map();
228
+ for (const change of compiled.changes)
229
+ toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
230
+ const changeSummary = [...toolCounts.entries()].map(([tool, count]) => ({ tool, count }));
231
+ const changePhrase = changeSummary.map(({ tool, count }) => (count > 1 ? `${tool}×${count}` : tool)).join("、") || "无";
232
+ console.error(`intent plan ${plan.planHash.slice(0, 12)}:${compiled.changes.length} 项变更(${changePhrase}),${compiled.skipped.length} 项已达标跳过,风险=${compiled.riskClass};环境=${plan.environment}`);
233
+ if (planOut !== undefined) {
234
+ // Lean route (2026-09-27): the plan is the immutable approval object and
235
+ // its full JSON is ~11 KB for a real requirement — inlining it charges the
236
+ // agent's window on every later turn. Write the byte-identical plan to a
237
+ // file inside the config root (so `intent apply` can read it) and return
238
+ // only the decision fields + the path.
239
+ writeFileSync(outPath, planJson, { encoding: "utf8", mode: 0o600 });
240
+ result = {
241
+ status: "ok",
242
+ planId: plan.planHash.slice(0, 12),
243
+ planHash: plan.planHash,
244
+ // Echo the caller's own path string: an absolute host path would be
245
+ // redacted by the envelope, and the caller already knows the name.
246
+ planFile: planOut,
247
+ changesCount: compiled.changes.length,
248
+ skippedCount: compiled.skipped.length,
249
+ riskClass: compiled.riskClass,
250
+ changeSummary,
251
+ planArtifactId: artifact.artifactId,
252
+ planArtifactSha256: artifact.sha256,
253
+ ...(warnings.length > 0 ? { warnings } : {}),
254
+ nextAction: `omc intent apply ${planOut}`,
255
+ };
256
+ }
257
+ else {
258
+ // Context-cost guard (round 15): a populated environment's decoded
259
+ // baseline makes the inline plan 50–60 KB, and a rich greenfield intent's
260
+ // plan is ~20 KB — either is the single largest payload any read face
261
+ // emits, charged on every later turn. Above the ceiling, externalize the
262
+ // (byte-identical) immutable plan to the config root, which `intent apply`
263
+ // can read, and return only decision fields + the path. Small plans stay
264
+ // inline (deterministic on size).
265
+ const baselineBytes = jsonBytes(baseline);
266
+ const overCeiling = planJson.length > INTENT_PLAN_INLINE_CEILING_BYTES || baselineBytes > INTENT_PLAN_INLINE_CEILING_BYTES;
267
+ // If the config root is not writable, fall back to the inline plan (the
268
+ // pre-existing behaviour) rather than turning a read-only cwd into a
269
+ // failure.
270
+ let externalized = false;
271
+ let planFileName = "";
272
+ if (overCeiling) {
273
+ planFileName = `intent-plan-${plan.planHash.slice(0, 12)}.json`;
274
+ try {
275
+ writeFileSync(join(intentConfigRoot(), planFileName), planJson, { encoding: "utf8", mode: 0o600 });
276
+ externalized = true;
277
+ }
278
+ catch {
279
+ externalized = false;
280
+ }
281
+ }
282
+ if (externalized) {
283
+ result = {
284
+ status: "ok",
285
+ planId: plan.planHash.slice(0, 12),
286
+ planHash: plan.planHash,
287
+ planFile: planFileName,
288
+ changesCount: compiled.changes.length,
289
+ skippedCount: compiled.skipped.length,
290
+ riskClass: compiled.riskClass,
291
+ changeSummary,
292
+ planArtifactId: artifact.artifactId,
293
+ planArtifactSha256: artifact.sha256,
294
+ planExternalized: true,
295
+ ...(warnings.length > 0 ? { warnings } : {}),
296
+ note: `plan ${planJson.length}B(基线 ${baselineBytes}B)超过内联上限 ${INTENT_PLAN_INLINE_CEILING_BYTES}B,未内联,已写入 ${planFileName};直接 ${"`omc intent apply " + planFileName + "`"}`,
297
+ nextAction: `omc intent apply ${planFileName}`,
298
+ };
299
+ }
300
+ else {
301
+ result = {
302
+ status: "ok",
303
+ planId: plan.planHash.slice(0, 12),
304
+ planHash: plan.planHash,
305
+ changesCount: compiled.changes.length,
306
+ skippedCount: compiled.skipped.length,
307
+ riskClass: compiled.riskClass,
308
+ plan,
309
+ planArtifactId: artifact.artifactId,
310
+ planArtifactSha256: artifact.sha256,
311
+ ...(warnings.length > 0 ? { warnings } : {}),
312
+ };
313
+ }
314
+ }
315
+ }
316
+ catch (error) {
317
+ if (error instanceof ToolRefusalError)
318
+ result = error.payload;
319
+ else if (error instanceof WireError)
320
+ result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
321
+ else
322
+ result = { status: "error", message: error instanceof Error ? error.message : String(error) };
323
+ }
324
+ return result;
325
+ }
326
+ /**
327
+ * Ticket 120 — `omc intent readback <file> --env <env>`: decode the live
328
+ * baseline back into intent shape and verify equivalence. equivalent:false
329
+ * stays exit 0 — it is a legitimate not-yet-applied state the agent decides
330
+ * on (re-plan is the repair path).
331
+ */
332
+ export async function readbackIntent(intent, environment) {
333
+ let result;
334
+ try {
335
+ const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
336
+ const session = await context.sessions.session(environment);
337
+ const verification = dietIntentLive(await verifyIntentLive(session, intent), context);
338
+ const equivalent = verification.equivalent === true;
339
+ const ignoredCount = Number(verification.ignoredOutsideIntentCount ?? verification.ignoredOutsideIntent?.length ?? 0);
340
+ console.error(`intent readback:${equivalent ? "等价 ✓" : `${verification.differences.length} 项差异`}${ignoredCount > 0 ? `(意图外资源 ${ignoredCount} 项按所有权忽略)` : ""}`);
341
+ result = { status: "ok", ...verification };
342
+ }
343
+ catch (error) {
344
+ if (error instanceof ToolRefusalError)
345
+ result = error.payload;
346
+ else if (error instanceof WireError)
347
+ result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
348
+ else
349
+ result = { status: "error", message: error instanceof Error ? error.message : String(error) };
350
+ }
351
+ return result;
352
+ }
353
+ /** r39 删除传播重试环(纯函数抽出便于单测):absent:true 计划给 3 次间隔读回,其余 1 次。 */
354
+ export async function verifyWithRetry(verifyOnce, attempts, planId, sleep = undefined) {
355
+ let verification = {};
356
+ let calls = 0;
357
+ let sleeps = 0;
358
+ const doSleep = sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
359
+ for (let attempt = 1;; attempt++) {
360
+ calls += 1;
361
+ verification = await verifyOnce();
362
+ if (verification.equivalent === true || attempt >= attempts)
363
+ break;
364
+ console.error(`intent apply ${planId}:删除后读回暂不等价(第 ${attempt}/${attempts} 次)——疑似删除传播中,${attempt === attempts - 1 ? "最后" : "1s"}后再核`);
365
+ await doSleep(1000);
366
+ sleeps += 1;
367
+ }
368
+ return { verification, calls, sleeps };
369
+ }
370
+ export async function applyIntent(planPath, options, ports) {
371
+ const emit = ports.emit;
372
+ const invalid = (blocker, message, code = "invalid-input") => ports.invalid(blocker, message, code);
373
+ let planText;
374
+ try {
375
+ planText = readIntentFile(intentConfigRoot(), planPath);
376
+ }
377
+ catch (fileError) {
378
+ // The plan for a large intent is externalized: `intent plan` returns
379
+ // `planFile` AND an artifactId. Callers read the id in the result and try
380
+ // `intent apply <artifactId>`, which used to fail with a bare "intent file
381
+ // does not exist" (bench40). Accept the artifact id directly, and otherwise
382
+ // name both recovery paths.
383
+ try {
384
+ planText = readArtifact(artifactRoot(), planPath).content.toString("utf8");
385
+ }
386
+ catch {
387
+ const hint = planPath.endsWith(".json") || planPath.includes("/") || planPath.includes("\\")
388
+ ? "plan 文件不存在或不在配置根内;plan 产物里有 planFile/artifactId 两种引用,直接给 planFile 或 artifactId 均可"
389
+ : `plan 找不到 "${planPath}"。大 plan 会外置——用 intent plan 结果里的 planFile 路径或 artifactId(可直接 \`omc intent apply <artifactId>\`);小 plan 内联在 result.plan,先落成 .json 再传入`;
390
+ return invalid(undefined, `${hint}(原错误:${fileError instanceof Error ? fileError.message : String(fileError)})`);
391
+ }
392
+ }
393
+ let parsed;
394
+ let autoPlanned = false;
395
+ try {
396
+ parsed = JSON.parse(planText);
397
+ }
398
+ catch (error) {
399
+ // r17 finding (two delegated agents, same wall): `intent apply <file>` read
400
+ // as "apply this intent file", but only a compiled plan JSON was accepted —
401
+ // each agent paid a help round-trip to discover plan --plan-out. Accept the
402
+ // SOURCE intent YAML directly: compile it in-process (same pipeline as
403
+ // `intent plan`) and apply the resulting plan.
404
+ const read = ports.readIntent ? ports.readIntent(planPath) : undefined;
405
+ if (read && read.kind === "ok") {
406
+ try {
407
+ const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
408
+ const session = await context.sessions.session(options.env);
409
+ const baseline = await readBaseline(session, read.intent);
410
+ const compiled = compileIntent(read.intent, baseline);
411
+ parsed = buildPlan({
412
+ environment: session.environmentName,
413
+ environmentFingerprint: session.environmentFingerprint(),
414
+ compiler: INTENT_COMPILER_VERSION,
415
+ baseline,
416
+ intent: read.intent,
417
+ compiled,
418
+ });
419
+ autoPlanned = true;
420
+ }
421
+ catch (planError) {
422
+ return invalid(undefined, `文件既不是合法 plan JSON,也不能编译为意图计划:${planError instanceof Error ? planError.message : String(planError)}(若想先看计划再应用:intent plan <yaml> --env <env> --plan-out <plan.json>)`);
423
+ }
424
+ }
425
+ else {
426
+ return invalid(undefined, `plan 文件不是合法 JSON:${error instanceof Error ? error.message : String(error)};意图 YAML 请先 intent plan <yaml> --plan-out <plan.json>,或直接 intent apply <yaml> --env <env>(自动编译)`);
427
+ }
428
+ }
429
+ const verified = verifyPlan(parsed);
430
+ if (!verified.ok)
431
+ return invalid(verified.code, verified.reason);
432
+ const plan = verified.plan;
433
+ const planId = plan.planHash.slice(0, 12);
434
+ if (options.env !== undefined && options.env !== plan.environment)
435
+ return invalid("intent-plan-environment-mismatch", `--env ${options.env} 与 plan 环境不一致(plan 属于 ${plan.environment};plan 是不可变审批对象,不跨环境迁移)`);
436
+ if (options.dryRun === true) {
437
+ const toolCounts = new Map();
438
+ for (const change of plan.changes)
439
+ toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
440
+ // r25 finding: this hint taught `intent apply <planId>` — apply only
441
+ // accepts a plan FILE / JSON / the source YAML (planId is a hash for
442
+ // reference, not an apply input). Point at the real re-entry.
443
+ console.error(`intent apply <意图.yaml> --env ${plan.environment} --dry-run:${plan.changes.length} 项变更预览,零环境写入(apply 收文件/YAML,不收 planId)`);
444
+ return emit({
445
+ status: "dry-run",
446
+ wouldChange: plan.changes.length > 0,
447
+ planId,
448
+ changes: plan.changes,
449
+ skippedCount: plan.skipped.length,
450
+ riskClass: plan.riskClass,
451
+ preview: [...toolCounts.entries()].map(([tool, count]) => (count > 1 ? `${tool}×${count}` : tool)).join("、") || "无变更",
452
+ });
453
+ }
454
+ // The whole apply holds the same cross-process write lock `omc call`
455
+ // acquires per write — one writer per environment across every face.
456
+ const lock = await acquireWriteLock(plan.environment);
457
+ if (!lock.held) {
458
+ const conflict = {
459
+ status: "refused",
460
+ blocker: "write-lock-held",
461
+ environment: plan.environment,
462
+ owner: lock.owner,
463
+ recovery: lock.recovery,
464
+ message: lock.recovery[0],
465
+ };
466
+ console.error(`write lock conflict: ${JSON.stringify(lock.owner)}`);
467
+ return emit(conflict);
468
+ }
469
+ try {
470
+ const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
471
+ const session = await context.sessions.session(plan.environment);
472
+ if (session.environmentFingerprint() !== plan.environmentFingerprint)
473
+ return emit({
474
+ status: "refused",
475
+ blocker: "intent-plan-environment-mismatch",
476
+ environment: session.environmentName,
477
+ message: "环境指纹与 plan 不一致——plan 不跨环境迁移;在目标环境重新 intent plan",
478
+ });
479
+ // Baseline drift gate (CONTEXT.md Configuration baseline): a fingerprint
480
+ // change after planning invalidates the plan and requires replanning.
481
+ const currentBaseline = await readBaseline(session, plan.intent);
482
+ if (baselineFingerprintOf(currentBaseline) !== plan.baselineFingerprint)
483
+ return emit({
484
+ status: "refused",
485
+ blocker: "intent-baseline-drifted",
486
+ environment: session.environmentName,
487
+ message: "plan 之后环境已变化(baseline fingerprint 不一致)——按配置基线契约,写入前必须重新 intent plan;绝不盲用过期 plan",
488
+ });
489
+ // Ticket 123 — the final confirmation: after the writes (or on an
490
+ // already-converged no-op) decode the authoritative live state back into
491
+ // intent shape and compare. A mismatch is NEVER reported as plain success
492
+ // (blocker intent-readback-mismatch + refusal exit); it means delivery is
493
+ // incomplete or drifted and the agent must re-plan, not retry blindly.
494
+ const confirmReadback = async (base) => {
495
+ if (options.verify === false)
496
+ return autoPlanned ? { ...base, plannedFrom: "intent-yaml" } : base;
497
+ let verification;
498
+ // r39: a destructive (absent:true) delete tears the model down in
499
+ // stages — fields/views drop first, the model shell lags a moment. An
500
+ // immediate readback then reports a phantom mismatch. Retry the live
501
+ // verification briefly (bounded) before declaring failure.
502
+ const declaresAbsence = plan.intent.models.some((model) => model.absent === true);
503
+ const attempt = await verifyWithRetry(async () => {
504
+ try {
505
+ const report = await verifyIntentLive(session, plan.intent);
506
+ return { ...dietIntentLive(report, context), status: report.equivalent === true ? "ok" : "verification-failed" };
507
+ }
508
+ catch (error) {
509
+ return { status: "error", equivalent: false, error: error instanceof Error ? error.message : String(error) };
510
+ }
511
+ }, declaresAbsence ? 3 : 1, planId, ports.sleep);
512
+ verification = attempt.verification;
513
+ if (verification.equivalent === true) {
514
+ console.error(`intent apply ${planId}:最终读回等价 ✓(意图 ${plan.intent.models.length} 个模型)`);
515
+ return { ...base, verification, ...(autoPlanned ? { plannedFrom: "intent-yaml" } : {}) };
516
+ }
517
+ console.error(`intent apply ${planId}:最终读回不等价——${Array.isArray(verification.differences) ? `${verification.differences.length} 项差异` : String(verification.error ?? "读回失败")};不得当作成功`);
518
+ return {
519
+ ...base,
520
+ status: "refused",
521
+ blocker: "intent-readback-mismatch",
522
+ verification,
523
+ ...(declaresAbsence
524
+ ? {
525
+ note: "本变更含 absent:true 删除:平台删除分阶段传播(字段/视图先撤、模型壳后撤),已做 3 次间隔读回仍不等价——先用 model.inspect 的 modelPresent 独立复核再判定;若 modelPresent:false 则删除实际已生效",
526
+ }
527
+ : {}),
528
+ message: "变更已执行,但最终权威读回与意图不等价:交付可能不完整或环境已漂移。按 differences 定位后重新 intent plan(不要盲重试)",
529
+ nextAction: "inspect verification.differences, then re-run intent plan against the live baseline",
530
+ };
531
+ };
532
+ if (plan.changes.length === 0) {
533
+ console.error(`intent apply ${planId}:零变更(环境已达标),无审批无写入`);
534
+ return emit(await confirmReadback({ status: "ok", planId, intentHash: plan.intentHash, applied: 0, skipped: plan.skipped, message: "环境已达标:零变更零审批" }));
535
+ }
536
+ const registry = buildToolRegistry();
537
+ const applyChanges = registry.find((definition) => definition.name === "apply_changes");
538
+ if (!applyChanges)
539
+ return emit({ status: "error", message: "apply_changes 工具不可用" });
540
+ const changes = plan.changes.map((change) => ({ tool: change.tool, args: change.args }));
541
+ const toolCounts = new Map();
542
+ for (const change of changes)
543
+ toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
544
+ const summary = `intent apply ${planId}:${changes.length} 项变更(${[...toolCounts.entries()].map(([tool, count]) => (count > 1 ? `${tool}×${count}` : tool)).join("、")})`;
545
+ let result;
546
+ try {
547
+ result = (await applyChanges.handler(context, parseToolInput(applyChanges, {
548
+ changes,
549
+ environment: plan.environment,
550
+ summary,
551
+ ...(options.confirmProduction === true ? { confirmProduction: true } : {}),
552
+ }), applyChanges));
553
+ }
554
+ catch (error) {
555
+ if (error instanceof ToolInputError)
556
+ result = error.payload;
557
+ else if (error instanceof ToolRefusalError)
558
+ result = error.payload;
559
+ else if (error instanceof WireError)
560
+ result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
561
+ else
562
+ result = { status: "error", message: error instanceof Error ? error.message : String(error) };
563
+ }
564
+ if (result.blocker === "production-confirmation-required")
565
+ console.error(`intent apply ${planId}:目标环境是生产环境——确认这是高危操作后,用 --confirm-production 重跑 apply(一次确认覆盖全部 ${changes.length} 步)`);
566
+ else
567
+ console.error(`intent apply ${planId}:${String(result.status)}(${changes.length} 项变更)`);
568
+ const applied = Array.isArray(result.steps) ? result.steps.length : undefined;
569
+ // Context-cost (round 15): the intent ladder's per-step `performed`/`readback`
570
+ // is redundant with the final intent readback-equivalence (the authority), and
571
+ // for a complex model it is ~17 KB of the envelope (listview columns, field
572
+ // publishCodes, datarule rows). Emit the step IDENTITY only; the full per-step
573
+ // bodies stay in the package/progress artifact (result.readback.progressFile).
574
+ const steps = Array.isArray(result.steps)
575
+ ? result.steps.map((step) => ({ step: step.step, index: step.index, status: step.status }))
576
+ : result.steps;
577
+ const delivered = { ...result, steps, planId, intentHash: plan.intentHash, ...(applied !== undefined && result.status === "ok" ? { applied } : {}) };
578
+ // Only a genuinely delivered package earns the final readback; a partial /
579
+ // refused / uncertain outcome already reports its own status and must not
580
+ // be papered over by a second read.
581
+ return emit(result.status === "ok" ? await confirmReadback(delivered) : delivered);
582
+ }
583
+ finally {
584
+ await lock.release();
585
+ }
586
+ }
587
+ //# sourceMappingURL=plan-apply.js.map