@stalfh233/omc-cli 0.4.35 → 0.4.37
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/README.md +2 -2
- package/dist/cli.js +42 -542
- package/dist/execution-metadata.js +24 -1159
- package/dist/intent/plan-apply.js +587 -0
- package/dist/lanes/runtime-data.js +1 -1
- package/dist/recipes/b2-registration.js +87 -0
- package/dist/recipes/businessrule.js +269 -0
- package/dist/recipes/datarule.js +215 -0
- package/dist/recipes/dictionary.js +42 -0
- package/dist/recipes/field.js +230 -0
- package/dist/recipes/form.js +19 -0
- package/dist/recipes/list-view.js +31 -0
- package/dist/recipes/model.js +34 -0
- package/dist/recipes/online-js.js +32 -0
- package/dist/recipes/runtime-data.js +113 -0
- package/dist/recipes/shared.js +8 -0
- package/dist/recipes/workflow.js +119 -0
- package/package.json +2 -2
- package/skills/omc-capabilities.md +1 -1
- package/skills/omc-start-here.md +2 -1
|
@@ -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
|
|
@@ -30,7 +30,7 @@ async function resolvePublishedWorkflowCode(session, schemaCode) {
|
|
|
30
30
|
const code = template && typeof template.workflowCode === "string" && template.workflowCode.length > 0 ? template.workflowCode : null;
|
|
31
31
|
return code;
|
|
32
32
|
}
|
|
33
|
-
function extractBizObjectPage(value) {
|
|
33
|
+
export function extractBizObjectPage(value) {
|
|
34
34
|
const root = value && typeof value === "object" ? value : {};
|
|
35
35
|
const candidate = root.bizObjectPage && typeof root.bizObjectPage === "object" ? root.bizObjectPage : root;
|
|
36
36
|
const content = Array.isArray(candidate.content) ? candidate.content.filter((row) => !!row && typeof row === "object") : [];
|