@stalfh233/omc-cli 0.6.12 → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/coverage/m1-coverage-manifest-v1.json +2 -0
- package/dist/approval-flow.js +84 -3
- package/dist/batch-call.js +32 -2
- package/dist/bench/call-face-eval.js +2 -2
- package/dist/call.js +35 -2
- package/dist/capability-overview.js +21 -7
- package/dist/cli-contract.js +8 -2
- package/dist/cli.js +313 -72
- package/dist/describe-cache.js +8 -4
- package/dist/discovery.js +4 -0
- package/dist/environment-policy.js +20 -0
- package/dist/field-families.js +62 -4
- package/dist/form-support.js +293 -6
- package/dist/goal-routes.js +137 -22
- package/dist/identity.js +63 -11
- package/dist/lanes/app-contract.js +4 -4
- package/dist/lanes/app.js +20 -2
- package/dist/lanes/dictionary-contract.js +16 -5
- package/dist/lanes/dictionary-coverage.js +1 -0
- package/dist/lanes/dictionary.js +200 -8
- package/dist/lanes/environment-contract.js +18 -6
- package/dist/lanes/environment.js +129 -16
- package/dist/lanes/field-contract.js +3 -2
- package/dist/lanes/field.js +785 -63
- package/dist/lanes/form.js +52 -6
- package/dist/lanes/list-view-contract.js +3 -3
- package/dist/lanes/list-view.js +143 -12
- package/dist/lanes/meta.js +36 -10
- package/dist/lanes/model-contract.js +7 -6
- package/dist/lanes/model.js +154 -10
- package/dist/lanes/organization.js +67 -7
- package/dist/lanes/runtime-data-contract.js +4 -2
- package/dist/lanes/runtime-data.js +395 -41
- package/dist/lanes/workflow-contract.js +2 -2
- package/dist/lanes/workflow.js +381 -11
- package/dist/model-scaffold.js +11 -3
- package/dist/path-projection.js +181 -31
- package/dist/read-shapes.js +31 -11
- package/dist/recipes/b2-registration.js +1 -1
- package/dist/recipes/field.js +16 -3
- package/dist/recipes/form.js +4 -3
- package/dist/recipes/runtime-data.js +58 -8
- package/dist/recipes/workflow.js +86 -1
- package/dist/refusal-guidance.js +18 -6
- package/dist/rollback-spec.js +41 -2
- package/dist/rollback.js +49 -11
- package/dist/script.js +72 -12
- package/dist/skills.js +91 -3
- package/dist/tools.js +113 -1
- package/dist/wire.js +13 -1
- package/dist/write-lock.js +92 -4
- package/dist/write.js +129 -16
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +1 -1
- package/skills/omc-capabilities.md +5 -4
- package/skills/omc-playbook.md +41 -0
- package/skills/omc-refusals.md +58 -6
- package/skills/omc-tool-selection.md +2 -0
- package/skills/omc-write-hazards.md +3 -2
package/dist/recipes/workflow.js
CHANGED
|
@@ -92,6 +92,91 @@ const workflowConditionalBranch = Object.freeze({
|
|
|
92
92
|
requiresEnvironmentDerivedIds: true,
|
|
93
93
|
prerequisiteData: { source: "workflow.get_draft", fields: ["activities[].activityCode", "org.role code"] },
|
|
94
94
|
});
|
|
95
|
+
/**
|
|
96
|
+
* r31c F24: two-level / one-level fork-then-join. The single-layer
|
|
97
|
+
* `workflow-conditional-branch` recipe only shows A2→A3(cond) / A2→END(else).
|
|
98
|
+
* Agents that extend it to "金额>X 走两级、否则一级" often add a second
|
|
99
|
+
* conditional edge on the same pre/post pair (401013) or leave A_mgr with no
|
|
100
|
+
* else outbound (dead-end graph that validate still marks valid:true — F23).
|
|
101
|
+
*
|
|
102
|
+
* Shape (amount > 50000 → mgr → gm → END; else mgr → END):
|
|
103
|
+
* START → 填写 → 主管审批 ─┬─(GT 50000)→ 总经理审批 → END
|
|
104
|
+
* └─(else)───────────────→ END
|
|
105
|
+
*/
|
|
106
|
+
const workflowConditionalTwoLevel = Object.freeze({
|
|
107
|
+
id: "workflow-conditional-two-level-approval",
|
|
108
|
+
title: "金额>阈值走两级审批,否则一级(分叉再汇合)",
|
|
109
|
+
status: "requires-prior-readback",
|
|
110
|
+
aliases: ["两级审批", "条件两级", "金额大于", "一级或两级", "分叉再汇合"],
|
|
111
|
+
tool: "workflow.update",
|
|
112
|
+
args: {
|
|
113
|
+
environment,
|
|
114
|
+
schemaCode: model,
|
|
115
|
+
code: "purchaseWorkflow",
|
|
116
|
+
template: {
|
|
117
|
+
workflowCode: "purchaseWorkflow",
|
|
118
|
+
activities: [
|
|
119
|
+
{ activityCode: "Activity1", activityType: "START", activityName: "开始", i18nMessage: "开始" },
|
|
120
|
+
{ activityCode: "Activity2", activityType: "PARTICIPANT", activityName: "填写申请单", i18nMessage: "填写申请单", participant: "{Originator}" },
|
|
121
|
+
{
|
|
122
|
+
activityCode: "A_mgr",
|
|
123
|
+
activityType: "PARTICIPANT",
|
|
124
|
+
activityName: "部门主管审批",
|
|
125
|
+
i18nMessage: "部门主管审批",
|
|
126
|
+
// Correct object shape (r31c F20): code/corpId/id/name/type — NOT participantType/roleId.
|
|
127
|
+
participant: JSON.stringify([{ code: "SYS_1000000", corpId: "other", id: "<roleId-from-org.role.list>", name: "主管", type: 2 }]),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
activityCode: "A_gm",
|
|
131
|
+
activityType: "PARTICIPANT",
|
|
132
|
+
activityName: "总经理审批",
|
|
133
|
+
i18nMessage: "总经理审批",
|
|
134
|
+
participant: JSON.stringify([{ code: "<gm-role-code>", corpId: "other", id: "<gm-roleId>", name: "总经理", type: 2 }]),
|
|
135
|
+
},
|
|
136
|
+
{ activityCode: "Activity5", activityType: "END", activityName: "结束", i18nMessage: "结束" },
|
|
137
|
+
],
|
|
138
|
+
routes: [
|
|
139
|
+
{ preNode: "Activity1", postNode: "Activity2" },
|
|
140
|
+
{ preNode: "Activity2", postNode: "A_mgr" },
|
|
141
|
+
// Fork at A_mgr: high amount → second level; else → END. Same pre/post
|
|
142
|
+
// pair must NOT carry both an unconditional edge and a condition edge
|
|
143
|
+
// (platform 401013). Else uses utilizeElse:true on a DIFFERENT postNode.
|
|
144
|
+
{ preNode: "A_mgr", postNode: "A_gm", ruleConditionGroup: [[{ currentDataItem: "totalAmount", bizPropertyType: 2, conditionType: "GT", value: "50000" }]] },
|
|
145
|
+
{ preNode: "A_mgr", postNode: "Activity5", utilizeElse: true },
|
|
146
|
+
{ preNode: "A_gm", postNode: "Activity5" },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
preconditions: [
|
|
151
|
+
"workflow.create 已完成;org.role.list 取真实角色 code/id 填 participant",
|
|
152
|
+
"数值字段已发布;bizPropertyType 用数字索引(NUMERICAL=2)",
|
|
153
|
+
"**整图替换**:省略 merge,否则旧 PARTICIPANT 节点会留下",
|
|
154
|
+
],
|
|
155
|
+
conditionalRequirements: [
|
|
156
|
+
"**participant 对象数组只认 `{code,corpId,id,name,type}`**(type 2=角色 3=用户)。`participantType`/`roleId`/`participantName` 会被平台静默丢掉,读回变成 `[{}]`,validate 仍 valid、publish 才 warning——待办派不出去(r31c F20)。",
|
|
157
|
+
"**同一对 pre/post 不能既有无条件边又有条件边**(平台 401013「节点名A&节点名B」)。条件边与 else 边的 postNode 必须不同。",
|
|
158
|
+
"**有条件边的节点必须有 else 兜底**,否则条件不满足时流程断头;`workflow.validate` 会以 `reachabilityWarnings` 点名这类节点(warning,不阻断发布)。",
|
|
159
|
+
"汇合边(A_gm→END)是无条件边;不要再给 A_mgr→A_gm 加一条无条件边。",
|
|
160
|
+
"线性无分支请用 `omc approval-flow`;本配方只覆盖条件两级/一级。",
|
|
161
|
+
],
|
|
162
|
+
priorToolReferences: [
|
|
163
|
+
{ tool: "workflow.get_draft", args: { environment, workflowCode: "purchaseWorkflow" }, purpose: "取草稿基线;默认面含 activityCode/edges" },
|
|
164
|
+
{ tool: "org.role.list", args: { environment }, purpose: "取角色 code/id 填 participant 对象(不要用自由文本名)" },
|
|
165
|
+
],
|
|
166
|
+
dryRun: { supported: true, command: `omc call workflow.update --env ${environment} --dry-run --args '<recipe.args>'`, notes: "dry-run 校验 activities/routes/条件叶;不写入" },
|
|
167
|
+
expectedReadback: {
|
|
168
|
+
readback: { draftUpdated: true, rules: 2 },
|
|
169
|
+
next: ["workflow.validate", "workflow.publish", "data.submit 两条(>50000 / ≤50000)验 workitem.list"],
|
|
170
|
+
},
|
|
171
|
+
refusalRecovery: [
|
|
172
|
+
{ code: "401013", action: "同一 pre/post 对重复连线或「条件边+无条件边」冲突——拆成不同 postNode,else 用 utilizeElse:true" },
|
|
173
|
+
{ code: "workflow-route-condition-invalid", action: "条件叶只留 currentDataItem/bizPropertyType/conditionType/value" },
|
|
174
|
+
{ code: "workflow-not-found", action: "先 workflow.create,再 get_draft 取基线" },
|
|
175
|
+
...commonRecovery,
|
|
176
|
+
],
|
|
177
|
+
requiresEnvironmentDerivedIds: true,
|
|
178
|
+
prerequisiteData: { source: "workflow.get_draft + org.role.list", fields: ["activities[].activityCode", "roles[].code", "roles[].id"] },
|
|
179
|
+
});
|
|
95
180
|
const workflowBinding = Object.freeze({
|
|
96
181
|
id: "workflow-handler-binding",
|
|
97
182
|
title: "将规则合并绑定到 workflow handler 并发布",
|
|
@@ -116,7 +201,7 @@ const workflowBinding = Object.freeze({
|
|
|
116
201
|
prerequisiteData: { source: "workflow.get_draft + rule.list", fields: ["workflow.code", "rule.code"] },
|
|
117
202
|
});
|
|
118
203
|
export const WORKFLOW_METADATA = Object.freeze({
|
|
119
|
-
"workflow.update": Object.freeze({ recipes: [workflowTemplateUpdate, workflowConditionalBranch], relatedTools: ["workflow.get_draft", "workflow.publish"] }),
|
|
204
|
+
"workflow.update": Object.freeze({ recipes: [workflowTemplateUpdate, workflowConditionalBranch, workflowConditionalTwoLevel], relatedTools: ["workflow.get_draft", "workflow.publish"] }),
|
|
120
205
|
"workflow.bindRule": Object.freeze({ recipes: [workflowBinding], relatedTools: ["workflow.create", "workflow.get_draft", "workflow.get_published", "rule.list"] }),
|
|
121
206
|
});
|
|
122
207
|
//# sourceMappingURL=workflow.js.map
|
package/dist/refusal-guidance.js
CHANGED
|
@@ -15,12 +15,18 @@ const SHAPE_GATES = Object.freeze({
|
|
|
15
15
|
recipeCommand: { tool: "data.submit", recipe: "plain-data-submit" },
|
|
16
16
|
doNot: "不要传毫秒数(如 1790000000000)——不报错但落库 NULL,读回才发现",
|
|
17
17
|
},
|
|
18
|
+
"child-rows-missing-id": {
|
|
19
|
+
whatWentWrong: "更新已有记录时,子表行没带 id",
|
|
20
|
+
correctShape: "三合一:既有行带 id(改);追加行 {…, rowStatus:\"Added\"};删除行 {id, rowStatus:\"Deleted\"}(只给 id)。少传行不会删",
|
|
21
|
+
recipeCommand: { tool: "data.update", recipe: "child-rows-upsert" },
|
|
22
|
+
doNot: "不要用同一批无 id 子行重试 update——每次都会再插一遍(r17 实测 2 行变 8 行);也不要靠少传行删行",
|
|
23
|
+
},
|
|
24
|
+
// 旧名兼容(r29b F7 前叫 submit-…,data.update 也会抛)
|
|
18
25
|
"submit-child-rows-missing-id": {
|
|
19
26
|
whatWentWrong: "更新已有记录时,子表行没带 id",
|
|
20
|
-
correctShape: "
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
doNot: "不要用同一批无 id 子行重试 update——每次都会再插一遍(r17 实测 2 行变 8 行)",
|
|
27
|
+
correctShape: "三合一:既有行带 id(改);追加行 {…, rowStatus:\"Added\"};删除行 {id, rowStatus:\"Deleted\"}(只给 id)。少传行不会删",
|
|
28
|
+
recipeCommand: { tool: "data.update", recipe: "child-rows-upsert" },
|
|
29
|
+
doNot: "不要用同一批无 id 子行重试 update——每次都会再插一遍(r17 实测 2 行变 8 行);也不要靠少传行删行",
|
|
24
30
|
},
|
|
25
31
|
"submit-approval-agree-misplaced": {
|
|
26
32
|
whatWentWrong: "agree 放在了 approval 对象里,平台只读顶层 agree",
|
|
@@ -105,7 +111,9 @@ const SHAPE_GATES = Object.freeze({
|
|
|
105
111
|
* retry command — a command that would fail again is worse than none.
|
|
106
112
|
*/
|
|
107
113
|
const FIX_FIRST_GATES = Object.freeze({
|
|
108
|
-
"workflow-start-permission-denied": "40009 有三个独立同码原因:① 发起人角色没挂该流程的发起权限(ouScope ∩ 发起人部门链,全空会静默回退发起人);② 流程未启用/未发布;③ 模型未绑定流程。先 omc call workflow.get_published
|
|
114
|
+
"workflow-start-permission-denied": "40009 有三个独立同码原因:① 发起人角色没挂该流程的发起权限(ouScope ∩ 发起人部门链,全空会静默回退发起人);② 流程未启用/未发布;③ 模型未绑定流程。先 omc call workflow.permissions / workflow.get_published 核对;管理员发起成功带 bypassReason 时不证明白名单生效——换非管理员环境验证。不要反复换参数重试 data.submit",
|
|
115
|
+
"workflow-bound-to-listview": "303024 列表绑定了流程:直接粘贴回执 nextCommand(listview.configure:actions:[] + replaceActions:true + 现成 fieldCodes)解绑,再 workflow.delete;整体清理用 model-purge",
|
|
116
|
+
"workflow-start-permission-empty": "发起权限表为空:除平台管理员外无人能发起。用 workflow.addPermission 授给部门/角色/用户;管理员 data.submit 成功不证明白名单生效",
|
|
109
117
|
"environment-write-disabled": "当前环境 profile 不可写:换可写环境,或让维护者在配置里开 writable——CLI 侧没有绕过开关",
|
|
110
118
|
"production-confirmation-required": "生产环境的写需要显式 confirmProduction:true——确认这次写入真的要落生产再加该参数,不要靠重试",
|
|
111
119
|
"app-not-published": "目标应用未发布:先 workflow/form/field 各面发布(或 app 级发布),再重试运行面操作",
|
|
@@ -191,7 +199,11 @@ export function refusalRecovery(blocker, payload, options = {}) {
|
|
|
191
199
|
// message/detail, so both refused and frozen (uncertain) envelopes teach it.
|
|
192
200
|
if (mentionsDashboardChannel(payload))
|
|
193
201
|
return dashboardBoundaryGuidance();
|
|
194
|
-
|
|
202
|
+
// r29b F7: blockerAliases 让旧名也能命中新引导。
|
|
203
|
+
const aliasBlockers = Array.isArray(payload.blockerAliases)
|
|
204
|
+
? payload.blockerAliases.filter((entry) => typeof entry === "string")
|
|
205
|
+
: [];
|
|
206
|
+
const shape = SHAPE_GATES[blocker] ?? aliasBlockers.map((alias) => SHAPE_GATES[alias]).find((entry) => entry !== undefined);
|
|
195
207
|
if (shape) {
|
|
196
208
|
const command = resolveCommand(shape, options.registry);
|
|
197
209
|
const { recipeCommand: _drop, ...rest } = shape;
|
package/dist/rollback-spec.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
/** Business-key extractors for array alignment in deepDiff (r30a F14/F15). */
|
|
2
|
+
const ARRAY_ALIGN_KEYS = ["code", "id", "propertyCode", "activityCode", "ruleCode", "nodeCode"];
|
|
3
|
+
function arrayEntryKey(entry) {
|
|
4
|
+
if (entry === null || typeof entry !== "object")
|
|
5
|
+
return undefined;
|
|
6
|
+
const record = entry;
|
|
7
|
+
for (const key of ARRAY_ALIGN_KEYS) {
|
|
8
|
+
const value = record[key];
|
|
9
|
+
if (value !== undefined && value !== null && String(value) !== "")
|
|
10
|
+
return `${key}:${String(value)}`;
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
1
14
|
/**
|
|
2
15
|
* Field-level deep diff for the plan surface. Returns at most `limit`
|
|
3
|
-
* changed paths plus the total count
|
|
4
|
-
* (
|
|
16
|
+
* changed paths plus the total count. Arrays of objects with a business key
|
|
17
|
+
* (`code`/`id`/…) are aligned by that key; index-only comparison is the
|
|
18
|
+
* fallback for scalars / unkeyed rows (r30a F14).
|
|
5
19
|
*/
|
|
6
20
|
export function deepDiff(before, current, path = "", limit = 40, acc = []) {
|
|
7
21
|
const walk = (b, c, p) => {
|
|
@@ -33,6 +47,31 @@ export function deepDiff(before, current, path = "", limit = 40, acc = []) {
|
|
|
33
47
|
const cA = c;
|
|
34
48
|
if (bA.length !== cA.length)
|
|
35
49
|
acc.push({ path: p, before: `<array×${bA.length}>`, current: `<array×${cA.length}>` });
|
|
50
|
+
const bKeys = bA.map(arrayEntryKey);
|
|
51
|
+
const cKeys = cA.map(arrayEntryKey);
|
|
52
|
+
const canAlign = bKeys.every((key) => key !== undefined) && cKeys.every((key) => key !== undefined)
|
|
53
|
+
&& new Set(bKeys).size === bKeys.length && new Set(cKeys).size === cKeys.length;
|
|
54
|
+
if (canAlign) {
|
|
55
|
+
const bMap = new Map(bKeys.map((key, index) => [key, bA[index]]));
|
|
56
|
+
const cMap = new Map(cKeys.map((key, index) => [key, cA[index]]));
|
|
57
|
+
for (const key of new Set([...bMap.keys(), ...cMap.keys()])) {
|
|
58
|
+
if (acc.length >= limit)
|
|
59
|
+
return;
|
|
60
|
+
const inB = bMap.has(key);
|
|
61
|
+
const inC = cMap.has(key);
|
|
62
|
+
const short = key.includes(":") ? key.slice(key.indexOf(":") + 1) : key;
|
|
63
|
+
if (!inB) {
|
|
64
|
+
acc.push({ path: `${p}[+${short}]`, before: "<absent>", current: cMap.get(key) });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!inC) {
|
|
68
|
+
acc.push({ path: `${p}[-${short}]`, before: bMap.get(key), current: "<absent>" });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
walk(bMap.get(key), cMap.get(key), `${p}[${short}]`);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
36
75
|
const shared = Math.min(bA.length, cA.length);
|
|
37
76
|
for (let i = 0; i < shared && acc.length < limit; i += 1)
|
|
38
77
|
walk(bA[i], cA[i], `${p}[${i}]`);
|
package/dist/rollback.js
CHANGED
|
@@ -43,8 +43,19 @@ export async function listRollback(options, context) {
|
|
|
43
43
|
entries = entries.filter((entry) => entry.target.includes(options.target));
|
|
44
44
|
if (options.tool)
|
|
45
45
|
entries = entries.filter((entry) => entry.tool === options.tool || entry.tool.startsWith(`${options.tool}.`));
|
|
46
|
+
// r30a F22: hide documented no-ops from the default timeline.
|
|
47
|
+
const actionable = entries.filter((entry) => entry.noop !== true);
|
|
46
48
|
const limit = options.limit ? Number(options.limit) : 40;
|
|
47
|
-
const timeline = [...
|
|
49
|
+
const timeline = [...actionable].reverse().slice(0, Number.isSafeInteger(limit) && limit > 0 ? limit : 40);
|
|
50
|
+
// r30a F9/F11/F20: surface which writes in this window cannot be undone.
|
|
51
|
+
const unrollbackable = timeline
|
|
52
|
+
.filter((entry) => entry.rollbackable === false)
|
|
53
|
+
.map((entry) => ({
|
|
54
|
+
seq: entry.seq,
|
|
55
|
+
tool: entry.tool,
|
|
56
|
+
target: entry.target,
|
|
57
|
+
reason: entry.note ?? "rollbackable:false",
|
|
58
|
+
}));
|
|
48
59
|
return {
|
|
49
60
|
status: "ok",
|
|
50
61
|
environment,
|
|
@@ -57,9 +68,11 @@ export async function listRollback(options, context) {
|
|
|
57
68
|
target: entry.target,
|
|
58
69
|
rollbackable: entry.rollbackable,
|
|
59
70
|
...(entry.note ? { note: entry.note } : {}),
|
|
71
|
+
...(entry.steps !== undefined ? { steps: entry.steps } : {}),
|
|
60
72
|
artifact: basename(entry.artifactFile),
|
|
61
73
|
})),
|
|
62
|
-
|
|
74
|
+
...(unrollbackable.length > 0 ? { unrollbackable } : {}),
|
|
75
|
+
hint: "omc rollback plan <seq> 预览恢复(不写);omc rollback apply <seq> 执行恢复;unrollbackable[] 是本窗口内撤不了的写",
|
|
63
76
|
};
|
|
64
77
|
}
|
|
65
78
|
async function buildPlan(ref, environmentOption, context) {
|
|
@@ -68,13 +81,20 @@ async function buildPlan(ref, environmentOption, context) {
|
|
|
68
81
|
const entry = resolveJournalEntry(environment, ref, ctx.artifactsDir);
|
|
69
82
|
if (!entry)
|
|
70
83
|
throw new Error(`journal(${environment})中找不到 ${ref}:先 omc rollback list 核对 seq`);
|
|
84
|
+
// r30a F9/F12: compound writes are journaled with rollbackable:false and may
|
|
85
|
+
// have NO RollbackSpec on the tool (configure_model). Do not call findSpec
|
|
86
|
+
// for the unrollbackable short-circuit — that used to throw and become exit 15.
|
|
71
87
|
const unrollbackable = (reason) => ({
|
|
72
88
|
entry,
|
|
73
|
-
spec:
|
|
89
|
+
spec: {
|
|
90
|
+
target: () => entry.target,
|
|
91
|
+
read: async () => null,
|
|
92
|
+
inverse: () => ({ kind: "irreversible", reason }),
|
|
93
|
+
},
|
|
74
94
|
before: undefined,
|
|
75
95
|
artifactArgs: {},
|
|
76
96
|
context: ctx,
|
|
77
|
-
plan: { environmentMatch: true, diff: { changes: [], total: 0 }, laterWrites:
|
|
97
|
+
plan: { environmentMatch: true, diff: { changes: [], total: 0 }, laterWrites: laterWritesOnTarget(environment, ctx.artifactsDir, entry.seq, entry.target), inverse: { kind: "irreversible", reason } },
|
|
78
98
|
});
|
|
79
99
|
if (!entry.rollbackable)
|
|
80
100
|
return unrollbackable(entry.note ?? "该写的 before 快照捕获失败,无法回滚");
|
|
@@ -122,13 +142,17 @@ export async function planRollback(ref, options, context) {
|
|
|
122
142
|
};
|
|
123
143
|
const script = plan.inverse.kind === "calls" ? plan.inverse.calls.map((call) => JSON.stringify({ tool: call.tool, args: { ...call.args, environment: entry.environment } })) : [];
|
|
124
144
|
const scriptOmitted = script.join("").length > 6000;
|
|
145
|
+
const irreversible = plan.inverse.kind === "irreversible";
|
|
125
146
|
return {
|
|
126
147
|
status: "ok",
|
|
127
148
|
rollbackOf: { seq: entry.seq, tool: entry.tool, target: entry.target, ts: entry.ts },
|
|
128
149
|
diff: { changedPaths: plan.diff.changes.map((change) => ({ path: change.path, before: preview(change.before), current: preview(change.current) })), total: plan.diff.total },
|
|
129
|
-
|
|
150
|
+
// r30a F12: do not claim "will overwrite later writes" when inverse is irreversible.
|
|
151
|
+
...(plan.laterWrites.length > 0 && !irreversible
|
|
130
152
|
? { warning: `该目标在此写之后还有 ${plan.laterWrites.length} 次写(seq ${plan.laterWrites.map((w) => w.seq).join(", ")});本次回滚会用 before 参数覆盖这些后续变更`, laterWrites: plan.laterWrites.map((w) => ({ seq: w.seq, tool: w.tool, ts: w.ts })) }
|
|
131
|
-
:
|
|
153
|
+
: plan.laterWrites.length > 0
|
|
154
|
+
? { laterWrites: plan.laterWrites.map((w) => ({ seq: w.seq, tool: w.tool, ts: w.ts })), laterWritesNote: "本写 irreversible,不会覆盖后续写;若要恢复后续变更请对那些 seq 分别 plan/apply" }
|
|
155
|
+
: {}),
|
|
132
156
|
inverse: plan.inverse.kind === "calls"
|
|
133
157
|
? {
|
|
134
158
|
kind: "calls",
|
|
@@ -139,19 +163,33 @@ export async function planRollback(ref, options, context) {
|
|
|
139
163
|
: { script }),
|
|
140
164
|
}
|
|
141
165
|
: { kind: "irreversible", reason: plan.inverse.reason, ...(plan.inverse.partial ? { partial: plan.inverse.partial } : {}) },
|
|
142
|
-
hint:
|
|
166
|
+
hint: irreversible
|
|
167
|
+
? `本写不可逆(apply 会 skipped/exit 0);${plan.laterWrites.length > 0 ? `下一步可试 omc rollback plan ${plan.laterWrites[0].seq} --env ${entry.environment}` : "无后续可恢复写"}`
|
|
168
|
+
: "预览不写入;确认后 omc rollback apply <seq>(恢复走完整写闸门,回滚本身也会记 journal,可再回滚)",
|
|
143
169
|
};
|
|
144
170
|
}
|
|
145
171
|
export async function applyRollback(ref, options, context) {
|
|
146
172
|
const loaded = await buildPlan(ref, options.environment, context);
|
|
147
173
|
const { entry, plan, spec, artifactArgs, before, context: ctx } = loaded;
|
|
148
|
-
if (plan.inverse.kind !== "calls" || plan.inverse.calls.length === 0)
|
|
174
|
+
if (plan.inverse.kind !== "calls" || plan.inverse.calls.length === 0) {
|
|
175
|
+
// r30a F12: irreversible is an expected answer → status:"skipped" + exit 0
|
|
176
|
+
// (was unclassified-status / exit 15). Keep reason + next step pasteable.
|
|
177
|
+
const inverse = plan.inverse;
|
|
178
|
+
const irreversible = inverse.kind === "irreversible";
|
|
179
|
+
const reason = irreversible ? inverse.reason : "无可执行恢复调用";
|
|
180
|
+
const later = plan.laterWrites;
|
|
149
181
|
return {
|
|
150
|
-
status:
|
|
182
|
+
status: irreversible ? "skipped" : "ok",
|
|
183
|
+
applied: false,
|
|
184
|
+
reason,
|
|
151
185
|
rollbackOf: { seq: entry.seq, tool: entry.tool, target: entry.target },
|
|
152
|
-
inverse
|
|
153
|
-
message:
|
|
186
|
+
inverse,
|
|
187
|
+
message: reason,
|
|
188
|
+
...(later.length > 0
|
|
189
|
+
? { nextCommand: `omc rollback plan ${later[0].seq} --env ${entry.environment}`, hint: `本写不可逆;可对后续写 seq ${later.map((w) => w.seq).join(", ")} 再 plan/apply` }
|
|
190
|
+
: {}),
|
|
154
191
|
};
|
|
192
|
+
}
|
|
155
193
|
const results = [];
|
|
156
194
|
for (const [index, call] of plan.inverse.calls.entries()) {
|
|
157
195
|
const { exitCode, result } = await runToolCall(call.tool, {
|
package/dist/script.js
CHANGED
|
@@ -5,44 +5,80 @@ import { SessionManager } from "./session-manager.js";
|
|
|
5
5
|
import { makeToolContext } from "./tools.js";
|
|
6
6
|
import { runToolCall } from "./call.js";
|
|
7
7
|
import { currentUsageId, recordScriptCall } from "./usage-log.js";
|
|
8
|
+
function isCommentOrBlank(line) {
|
|
9
|
+
return line.length === 0 || line.startsWith("#") || line.startsWith("//");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Parse NDJSON. Blank / `#` / `//` comment lines are ignored. Other illegal
|
|
13
|
+
* lines are collected as `skipped` (r31b F16/F17) so a trailing `// total 32`
|
|
14
|
+
* no longer aborts the whole batch before any call runs.
|
|
15
|
+
*/
|
|
8
16
|
export function parseScript(text) {
|
|
9
17
|
const lines = [];
|
|
18
|
+
const skipped = [];
|
|
10
19
|
const raw = text.split("\n");
|
|
11
20
|
for (const [index, untrimmed] of raw.entries()) {
|
|
12
21
|
const line = untrimmed.trim();
|
|
13
|
-
if (line
|
|
22
|
+
if (isCommentOrBlank(line))
|
|
14
23
|
continue;
|
|
15
24
|
let parsed;
|
|
16
25
|
try {
|
|
17
26
|
parsed = JSON.parse(line);
|
|
18
27
|
}
|
|
19
28
|
catch (error) {
|
|
20
|
-
|
|
29
|
+
skipped.push({
|
|
30
|
+
sourceLine: index + 1,
|
|
31
|
+
reason: `不是合法 JSON:${error instanceof Error ? error.message : String(error)}`,
|
|
32
|
+
text: line.slice(0, 120),
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
21
35
|
}
|
|
22
36
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || typeof parsed.tool !== "string") {
|
|
23
|
-
|
|
37
|
+
skipped.push({
|
|
38
|
+
sourceLine: index + 1,
|
|
39
|
+
reason: `必须是 {"tool":"<名称>","args":{...}} 形状(tool 必填)`,
|
|
40
|
+
text: line.slice(0, 120),
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
24
43
|
}
|
|
25
44
|
const entry = parsed;
|
|
26
|
-
if (entry.args !== undefined && (entry.args === null || typeof entry.args !== "object" || Array.isArray(entry.args)))
|
|
27
|
-
|
|
45
|
+
if (entry.args !== undefined && (entry.args === null || typeof entry.args !== "object" || Array.isArray(entry.args))) {
|
|
46
|
+
skipped.push({
|
|
47
|
+
sourceLine: index + 1,
|
|
48
|
+
reason: "args 必须是 JSON 对象",
|
|
49
|
+
text: line.slice(0, 120),
|
|
50
|
+
});
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
28
53
|
lines.push({
|
|
29
54
|
tool: entry.tool,
|
|
30
55
|
...(typeof entry.args === "object" && entry.args !== null ? { args: entry.args } : {}),
|
|
31
56
|
...(typeof entry.env === "string" ? { env: entry.env } : {}),
|
|
32
57
|
...(entry.dryRun === true ? { dryRun: true } : {}),
|
|
58
|
+
sourceLine: index + 1,
|
|
33
59
|
});
|
|
34
60
|
}
|
|
35
|
-
return lines;
|
|
61
|
+
return { lines, skipped };
|
|
36
62
|
}
|
|
37
63
|
export async function runScript(lines, options = {}) {
|
|
38
64
|
const print = options.print ?? ((line) => console.log(line));
|
|
39
65
|
const context = options.context ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
|
|
40
|
-
// The outer `script` invocation is already recorded by completeUsage; these
|
|
41
|
-
// inner records are what make a batch measurable at all. Before this, all 23
|
|
42
|
-
// `script -` calls in round 10 logged stdoutBytes=0 and the actual work was
|
|
43
|
-
// invisible (the outer record says "script -", the log says nothing else).
|
|
44
66
|
const parentId = currentUsageId();
|
|
67
|
+
const skipped = options.skipped ?? [];
|
|
68
|
+
for (const skip of skipped) {
|
|
69
|
+
print(JSON.stringify({
|
|
70
|
+
kind: "script-skipped",
|
|
71
|
+
sourceLine: skip.sourceLine,
|
|
72
|
+
reason: skip.reason,
|
|
73
|
+
text: skip.text,
|
|
74
|
+
note: "非法行已跳过(# 与 // 开头的注释/空行会被忽略;其它非法行计入 summary.skipped)",
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
45
77
|
let failed = false;
|
|
78
|
+
let ok = 0;
|
|
79
|
+
let failedCount = 0;
|
|
80
|
+
const failedIndexes = [];
|
|
81
|
+
let stoppedAt;
|
|
46
82
|
for (const [index, line] of lines.entries()) {
|
|
47
83
|
const startedMs = Date.now();
|
|
48
84
|
const { exitCode, result } = await runToolCall(line.tool, {
|
|
@@ -67,12 +103,36 @@ export async function runScript(lines, options = {}) {
|
|
|
67
103
|
print(rendered);
|
|
68
104
|
if (exitCode !== 0) {
|
|
69
105
|
failed = true;
|
|
106
|
+
failedCount += 1;
|
|
107
|
+
failedIndexes.push(index);
|
|
70
108
|
if (options.continueOnError !== true) {
|
|
71
|
-
|
|
72
|
-
|
|
109
|
+
stoppedAt = index;
|
|
110
|
+
print(JSON.stringify({
|
|
111
|
+
kind: "script-stopped",
|
|
112
|
+
stoppedAtIndex: index,
|
|
113
|
+
tool: line.tool,
|
|
114
|
+
remaining: lines.length - index - 1,
|
|
115
|
+
note: `第 ${index + 1} 行失败,后续 ${lines.length - index - 1} 行未执行;加 --continue 跑完全部(默认首败即停)`,
|
|
116
|
+
}));
|
|
117
|
+
break;
|
|
73
118
|
}
|
|
74
119
|
}
|
|
120
|
+
else {
|
|
121
|
+
ok += 1;
|
|
122
|
+
}
|
|
75
123
|
}
|
|
124
|
+
print(JSON.stringify({
|
|
125
|
+
summary: {
|
|
126
|
+
total: lines.length,
|
|
127
|
+
ok,
|
|
128
|
+
failed: failedCount,
|
|
129
|
+
skipped: skipped.length,
|
|
130
|
+
...(failedIndexes.length > 0 ? { failedIndexes } : {}),
|
|
131
|
+
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
if (lines.length === 0 && skipped.length > 0)
|
|
135
|
+
return 10; // invalid-input: nothing runnable
|
|
76
136
|
return failed ? 1 : 0;
|
|
77
137
|
}
|
|
78
138
|
export function readScriptInput(path) {
|
package/dist/skills.js
CHANGED
|
@@ -34,9 +34,78 @@ function skillFiles() {
|
|
|
34
34
|
return [];
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
/** r29a F4:合成 topic——跨 app/folder/model/rule 的应用参数矩阵(不落盘 md,省预算)。 */
|
|
38
|
+
/** r31b F4:合成 topic——并发语义(真机实测结论,不落盘 md)。 */
|
|
39
|
+
const SYNTHETIC_SKILLS = Object.freeze({
|
|
40
|
+
"app-scope": {
|
|
41
|
+
name: "app-scope",
|
|
42
|
+
description: "跨 app/folder/model/rule 的应用范围参数名矩阵(主名/别名)",
|
|
43
|
+
content: [
|
|
44
|
+
"# 应用范围参数(app-scope)",
|
|
45
|
+
"",
|
|
46
|
+
"同一能力族里应用参数名不完全统一。一次看完,别逐个 `describe --full`。",
|
|
47
|
+
"",
|
|
48
|
+
"## 矩阵",
|
|
49
|
+
"",
|
|
50
|
+
"| 工具面 | 主参数 | 别名 | 备注 |",
|
|
51
|
+
"| --- | --- | --- | --- |",
|
|
52
|
+
"| `app.*` | (多数无;create/delete 用 `code`) | — | 管应用包本身 |",
|
|
53
|
+
"| `folder.*` | `applicationCode` | `appCode` | 双别名;冲突报 `app-code-conflict` |",
|
|
54
|
+
"| `model.*` | `applicationCode` | **不认** `appCode` | 传 `appCode` → `unknown-app-scope-arg` |",
|
|
55
|
+
"| `rule.*` | **无** | — | 按 `schemaCode` 定位;传应用键 → `tool-has-no-app-scope` |",
|
|
56
|
+
"| `report.*` | `appCode` | — | 报表面主用短名 |",
|
|
57
|
+
"",
|
|
58
|
+
"## 怎么查",
|
|
59
|
+
"",
|
|
60
|
+
"- 单能力:`omc capability model` / `folder`(回执带 `appScopeArgs`)",
|
|
61
|
+
"- 单工具:`omc describe <tool> --param applicationCode`",
|
|
62
|
+
"- 本 topic:`omc help app-scope`",
|
|
63
|
+
"",
|
|
64
|
+
"## 易踩坑",
|
|
65
|
+
"",
|
|
66
|
+
"1. `folder.tree` 能用 `appCode`,不能据此推断 `model.list` 也认。",
|
|
67
|
+
"2. `rule.list` 传 `applicationCode` 会被拒——规则面没有应用维度。",
|
|
68
|
+
"3. 不存在的 `applicationCode` 在 `model.list` 会 `app-not-found`(不是空列表)。",
|
|
69
|
+
"",
|
|
70
|
+
].join("\n"),
|
|
71
|
+
},
|
|
72
|
+
concurrency: {
|
|
73
|
+
name: "concurrency",
|
|
74
|
+
description: "环境写锁 / 记录级 last-writer-wins / version 非乐观锁(r31 真机)",
|
|
75
|
+
content: [
|
|
76
|
+
"# 并发语义(r31 真机)",
|
|
77
|
+
"",
|
|
78
|
+
"写锁是**环境级**;记录级**无乐观锁**。CLI 不提供 `ifVersion`。以下全部双进程实测。",
|
|
79
|
+
"",
|
|
80
|
+
"| 操作 | 保护 | 结果 |",
|
|
81
|
+
"| --- | --- | --- |",
|
|
82
|
+
"| 并行写 | 环境写锁 | 输家 `write-lock-held`(`retryable`/`lockOwner`/`nextCommand`) |",
|
|
83
|
+
"| 同记录 `data.update` 不同字段 +`--wait` | 写锁串行+patch | 两字段都保住;**同字段**后写覆盖 |",
|
|
84
|
+
"| 读-改-写(旧值算新值) | **无** | 后写覆盖 |",
|
|
85
|
+
"| 行 `version` | 只读计数器 | update 成功 +1;`formData.version` 静默忽略(**不是乐观锁**) |",
|
|
86
|
+
"| 并行同 code 字段/模型 | 写锁+存在性闸 | 一成功;一 `duplicate-field`/`model-create-duplicate` |",
|
|
87
|
+
"| `data.list` 翻页 vs 写 | 读无快照 | 无 `snapshotAt`;可重复/漏行,`total` 可漂移 |",
|
|
88
|
+
"| 同 id `data.delete` | 写锁串行 | 第二次 `performed.skipped:\"already-absent\"` |",
|
|
89
|
+
"",
|
|
90
|
+
"`data.list` 唯一一致性键是 `filter.draftPolicy`(草稿是否计入)。",
|
|
91
|
+
"",
|
|
92
|
+
"读面数组键:`field.list`→`fields`;`model.list`→`models`;`data.list`→`items`(别用错 `--jq`)。",
|
|
93
|
+
"",
|
|
94
|
+
"```bash",
|
|
95
|
+
"(omc call data.save --env local --args '{\"schemaCode\":\"<m>\",\"formData\":{\"code\":\"a\",\"value\":1}}') &",
|
|
96
|
+
"(omc call data.save --env local --args '{\"schemaCode\":\"<m>\",\"formData\":{\"code\":\"b\",\"value\":2}}') & wait",
|
|
97
|
+
"```",
|
|
98
|
+
"",
|
|
99
|
+
"- 写串行或 `--wait`;**不要** shell 重试循环。",
|
|
100
|
+
"- 批量用 `omc script`(同进程更快);**测写锁必须多进程 `omc call`**。",
|
|
101
|
+
"- 详见 `omc help omc-write-hazards --section \"写调用纪律\"`。",
|
|
102
|
+
"",
|
|
103
|
+
].join("\n"),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
37
106
|
/** Every methodology topic exposed by `omc help`. */
|
|
38
107
|
export function listSkills() {
|
|
39
|
-
|
|
108
|
+
const files = skillFiles().map((file) => {
|
|
40
109
|
const name = file.replace(/\.md$/, "");
|
|
41
110
|
let description = `OMC methodology: ${name}`;
|
|
42
111
|
try {
|
|
@@ -47,6 +116,8 @@ export function listSkills() {
|
|
|
47
116
|
}
|
|
48
117
|
return { name, description };
|
|
49
118
|
});
|
|
119
|
+
const synthetic = Object.values(SYNTHETIC_SKILLS).map(({ name, description }) => ({ name, description }));
|
|
120
|
+
return [...files, ...synthetic].sort((left, right) => left.name.localeCompare(right.name));
|
|
50
121
|
}
|
|
51
122
|
/**
|
|
52
123
|
* r43 后 backlog:`omc help 编号` 曾零命中(高频中文业务词不是 topic 名)。
|
|
@@ -76,6 +147,11 @@ const HELP_TOPIC_SYNONYMS = Object.freeze({
|
|
|
76
147
|
验收: ["omc-acceptance-criteria"],
|
|
77
148
|
入门: ["omc-start-here"],
|
|
78
149
|
拒绝: ["omc-refusals"],
|
|
150
|
+
并发: ["concurrency", "omc-write-hazards"],
|
|
151
|
+
竞争: ["concurrency"],
|
|
152
|
+
乐观锁: ["concurrency"],
|
|
153
|
+
写锁: ["concurrency", "omc-write-hazards"],
|
|
154
|
+
快照: ["concurrency"],
|
|
79
155
|
});
|
|
80
156
|
export function suggestHelpTopics(query) {
|
|
81
157
|
if (query === undefined || query.length === 0)
|
|
@@ -99,15 +175,25 @@ export function suggestHelpTopics(query) {
|
|
|
99
175
|
}
|
|
100
176
|
return [...hits].slice(0, 5);
|
|
101
177
|
}
|
|
178
|
+
/** Appended to every `--all` body so the probe learns the cheap face (r31c F2). */
|
|
179
|
+
const HELP_ALL_SECTION_HINT = '\n\n> hint:"下次可按节读:omc help <topic> --section <标题>"\n';
|
|
102
180
|
/** Read one methodology topic for `omc help <topic>`. */
|
|
103
181
|
export function readSkill(name) {
|
|
104
182
|
if (!/^[a-z0-9-]+$/.test(name))
|
|
105
183
|
return undefined;
|
|
184
|
+
if (SYNTHETIC_SKILLS[name] !== undefined) {
|
|
185
|
+
const synthetic = SYNTHETIC_SKILLS[name];
|
|
186
|
+
return { ...synthetic, content: `${synthetic.content.trimEnd()}${HELP_ALL_SECTION_HINT}` };
|
|
187
|
+
}
|
|
106
188
|
const entry = listSkills().find((skill) => skill.name === name);
|
|
107
189
|
if (entry === undefined)
|
|
108
190
|
return undefined;
|
|
109
191
|
try {
|
|
110
|
-
|
|
192
|
+
const content = normalizeLineEndings(readFileSync(join(skillsDir, `${name}.md`), "utf8"));
|
|
193
|
+
// r31c F2: `--all` used to dump 5–14KB with no "use --section next time"
|
|
194
|
+
// hint (that tip lived only on the index face / cli.ts). Append one line so
|
|
195
|
+
// the expensive face teaches the cheap face. Index blurbs stay ≤40 chars.
|
|
196
|
+
return { ...entry, content: `${content.trimEnd()}${HELP_ALL_SECTION_HINT}` };
|
|
111
197
|
}
|
|
112
198
|
catch {
|
|
113
199
|
return undefined;
|
|
@@ -210,7 +296,9 @@ export function readSkillSection(name, requested) {
|
|
|
210
296
|
*/
|
|
211
297
|
/** First meaningful body line of a section: skip headings/blank/fence/list markers. */
|
|
212
298
|
function sectionBlurb(body) {
|
|
213
|
-
|
|
299
|
+
// r31c F2: index blurbs must stay ≤40 chars so a bare `omc help <topic>`
|
|
300
|
+
// is enough to pick the right section (probe had to open --all / second round).
|
|
301
|
+
const limit = 40;
|
|
214
302
|
// sliceSectionText includes the section's own heading line — skip it.
|
|
215
303
|
for (const raw of body.split(/\r?\n/).slice(1)) {
|
|
216
304
|
const line = raw.trim();
|