@stalfh233/omc-cli 0.0.0-stage → 0.4.0
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/LICENSE +21 -0
- package/README.md +304 -3
- package/coverage/m1-coverage-manifest-v1.json +1278 -0
- package/dist/approval-token.js +102 -0
- package/dist/args.js +25 -0
- package/dist/artifacts.js +119 -0
- package/dist/bench/call-face-eval.js +256 -0
- package/dist/bench/context-attribution.js +151 -0
- package/dist/bench/discovery-cost-eval.js +230 -0
- package/dist/bench/driver.js +81 -0
- package/dist/bench/evals.js +236 -0
- package/dist/bench/fake-http-server.js +65 -0
- package/dist/bench/instrument.js +87 -0
- package/dist/bench/intent-face-eval.js +343 -0
- package/dist/bench/run.js +166 -0
- package/dist/bench/scenario.js +343 -0
- package/dist/bench/types.js +76 -0
- package/dist/bi-wire.js +41 -0
- package/dist/bizservice-config.js +581 -0
- package/dist/call.js +153 -0
- package/dist/capability-absences.js +23 -0
- package/dist/capability-overview.js +492 -0
- package/dist/capability-shape.js +154 -0
- package/dist/cli-contract.js +70 -0
- package/dist/cli-output.js +73 -0
- package/dist/cli.js +1418 -0
- package/dist/code-rules.js +69 -0
- package/dist/command-transport.js +155 -0
- package/dist/config-store.js +195 -0
- package/dist/context.js +21 -0
- package/dist/contract-consistency.js +66 -0
- package/dist/contract-resources.js +62 -0
- package/dist/coverage-consistency.js +62 -0
- package/dist/coverage-registry.js +76 -0
- package/dist/coverage.js +130 -0
- package/dist/data-list-filter.js +114 -0
- package/dist/discovery.js +390 -0
- package/dist/endpoints.js +154 -0
- package/dist/environment-policy.js +26 -0
- package/dist/execution-metadata.js +1092 -0
- package/dist/fake/app.js +45 -0
- package/dist/fake/b2-registration.js +594 -0
- package/dist/fake/businessrule.js +242 -0
- package/dist/fake/datarule.js +72 -0
- package/dist/fake/dictionary.js +108 -0
- package/dist/fake/environment.js +30 -0
- package/dist/fake/field.js +167 -0
- package/dist/fake/form.js +101 -0
- package/dist/fake/index.js +121 -0
- package/dist/fake/list-view.js +289 -0
- package/dist/fake/model.js +136 -0
- package/dist/fake/online-js.js +18 -0
- package/dist/fake/report.js +253 -0
- package/dist/fake/routes.js +47 -0
- package/dist/fake/rule-lifecycle.js +37 -0
- package/dist/fake/runtime-data.js +367 -0
- package/dist/fake/state.js +67 -0
- package/dist/fake/workflow.js +364 -0
- package/dist/field-change.js +200 -0
- package/dist/field-families.js +896 -0
- package/dist/form-layout.js +111 -0
- package/dist/form-support.js +821 -0
- package/dist/goal-routes.js +468 -0
- package/dist/governed-execution.js +87 -0
- package/dist/human-summary.js +212 -0
- package/dist/identity.js +62 -0
- package/dist/intent/baseline.js +57 -0
- package/dist/intent/capabilities/bizservice.js +274 -0
- package/dist/intent/capabilities/businessrule.js +493 -0
- package/dist/intent/capabilities/datarule.js +187 -0
- package/dist/intent/capabilities/field.js +545 -0
- package/dist/intent/capabilities/form.js +136 -0
- package/dist/intent/capabilities/index.js +64 -0
- package/dist/intent/capabilities/listview.js +157 -0
- package/dist/intent/capabilities/model.js +100 -0
- package/dist/intent/capabilities/onlinejs.js +108 -0
- package/dist/intent/capabilities/report.js +355 -0
- package/dist/intent/capabilities/workflow.js +458 -0
- package/dist/intent/capability.js +6 -0
- package/dist/intent/cli.js +91 -0
- package/dist/intent/compare.js +56 -0
- package/dist/intent/compiler.js +79 -0
- package/dist/intent/dsl.js +129 -0
- package/dist/intent/plan-file.js +63 -0
- package/dist/intent/readback.js +65 -0
- package/dist/intent/schema.js +158 -0
- package/dist/intent/validation.js +30 -0
- package/dist/intent/yaml.js +315 -0
- package/dist/json-column.js +68 -0
- package/dist/lanes/app-contract.js +95 -0
- package/dist/lanes/app-coverage.js +16 -0
- package/dist/lanes/app.js +174 -0
- package/dist/lanes/apply-changes.js +231 -0
- package/dist/lanes/b2-registration-contract.js +292 -0
- package/dist/lanes/b2-registration-coverage.js +48 -0
- package/dist/lanes/b2-registration.js +1187 -0
- package/dist/lanes/businessrule-contract.js +232 -0
- package/dist/lanes/businessrule-coverage.js +16 -0
- package/dist/lanes/businessrule.js +221 -0
- package/dist/lanes/contract-support.js +65 -0
- package/dist/lanes/coverage-declaration.js +9 -0
- package/dist/lanes/datarule-contract.js +155 -0
- package/dist/lanes/datarule-coverage.js +19 -0
- package/dist/lanes/datarule-protocol.js +308 -0
- package/dist/lanes/datarule.js +818 -0
- package/dist/lanes/dictionary-contract.js +78 -0
- package/dist/lanes/dictionary-coverage.js +24 -0
- package/dist/lanes/dictionary.js +235 -0
- package/dist/lanes/environment-contract.js +70 -0
- package/dist/lanes/environment-coverage.js +14 -0
- package/dist/lanes/environment.js +163 -0
- package/dist/lanes/field-contract.js +223 -0
- package/dist/lanes/field-coverage.js +27 -0
- package/dist/lanes/field.js +374 -0
- package/dist/lanes/form-contract.js +97 -0
- package/dist/lanes/form-coverage.js +16 -0
- package/dist/lanes/form.js +185 -0
- package/dist/lanes/lane-ids.js +34 -0
- package/dist/lanes/list-view-contract.js +175 -0
- package/dist/lanes/list-view-coverage.js +20 -0
- package/dist/lanes/list-view-shapes.js +1207 -0
- package/dist/lanes/list-view.js +578 -0
- package/dist/lanes/meta-contract.js +85 -0
- package/dist/lanes/meta.js +255 -0
- package/dist/lanes/model-contract.js +168 -0
- package/dist/lanes/model-coverage.js +20 -0
- package/dist/lanes/model.js +986 -0
- package/dist/lanes/online-js-contract.js +99 -0
- package/dist/lanes/online-js-coverage.js +28 -0
- package/dist/lanes/online-js.js +127 -0
- package/dist/lanes/report-contract.js +144 -0
- package/dist/lanes/report-coverage.js +21 -0
- package/dist/lanes/report.js +476 -0
- package/dist/lanes/rule-graph.js +1846 -0
- package/dist/lanes/rule-lifecycle-contract.js +92 -0
- package/dist/lanes/rule-lifecycle-coverage.js +20 -0
- package/dist/lanes/rule-lifecycle.js +176 -0
- package/dist/lanes/runtime-data-contract.js +264 -0
- package/dist/lanes/runtime-data-coverage.js +25 -0
- package/dist/lanes/runtime-data.js +1054 -0
- package/dist/lanes/workflow-contract.js +223 -0
- package/dist/lanes/workflow-coverage.js +40 -0
- package/dist/lanes/workflow.js +1813 -0
- package/dist/online-js-layout.js +58 -0
- package/dist/online-js-source.js +276 -0
- package/dist/package-tool.js +51 -0
- package/dist/package.js +73 -0
- package/dist/plan.js +73 -0
- package/dist/read.js +144 -0
- package/dist/redact.js +28 -0
- package/dist/rule-support.js +587 -0
- package/dist/runtime-support.js +134 -0
- package/dist/server.js +92 -0
- package/dist/session-manager.js +30 -0
- package/dist/session.js +149 -0
- package/dist/skills.js +112 -0
- package/dist/support.js +98 -0
- package/dist/tool-types.js +127 -0
- package/dist/tools.js +59 -0
- package/dist/usage-log.js +197 -0
- package/dist/wire.js +287 -0
- package/dist/workflow-support.js +99 -0
- package/dist/write-lease.js +26 -0
- package/dist/write-lock.js +109 -0
- package/dist/write.js +176 -0
- package/dist/zip.js +156 -0
- package/docs/tool-surface-map.md +42 -0
- package/package.json +71 -6
- package/skills/omc-acceptance-criteria.md +47 -0
- package/skills/omc-business-configuration.md +257 -0
- package/skills/omc-capabilities.md +197 -0
- package/skills/omc-capability-scouting.md +55 -0
- package/skills/omc-config-draft-review.md +171 -0
- package/skills/omc-five-piece-flow.md +35 -0
- package/skills/omc-glossary.md +86 -0
- package/skills/omc-refusals.md +110 -0
- package/skills/omc-requirement-analysis.md +161 -0
- package/skills/omc-requirement-vocabulary.md +51 -0
- package/skills/omc-start-here.md +82 -0
- package/skills/omc-tool-selection.md +119 -0
- package/skills/omc-write-hazards.md +87 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ENDPOINTS } from "../endpoints.js";
|
|
3
|
+
import { capabilityArg, environmentArg } from "../args.js";
|
|
4
|
+
import { governedRead } from "../read.js";
|
|
5
|
+
import { writeTool } from "../write.js";
|
|
6
|
+
import { defineTool, ToolRefusalError } from "../tool-types.js";
|
|
7
|
+
import { wireCall, wireRows } from "../wire.js";
|
|
8
|
+
import { APP_LANE_CONTRACT } from "./app-contract.js";
|
|
9
|
+
const APP_LANE = APP_LANE_CONTRACT.lane;
|
|
10
|
+
const APP_TOOLS = APP_LANE_CONTRACT.tools;
|
|
11
|
+
/**
|
|
12
|
+
* Portal visibility is group membership: the portal renders NAMED groups, and
|
|
13
|
+
* an app parked in the fallback group (`code === "all"`, case-insensitive) is
|
|
14
|
+
* not rendered. Expose the derived boolean so a readback can assert it instead
|
|
15
|
+
* of leaving the caller to infer it from `warning` (dogfood R3).
|
|
16
|
+
*/
|
|
17
|
+
function isFallbackGroup(group) {
|
|
18
|
+
return String(group?.code ?? "").toLowerCase() === "all";
|
|
19
|
+
}
|
|
20
|
+
export function registerAppLane(tools) {
|
|
21
|
+
tools.push(defineTool({
|
|
22
|
+
name: "app.list",
|
|
23
|
+
layer: "low",
|
|
24
|
+
kind: "read",
|
|
25
|
+
lane: APP_LANE,
|
|
26
|
+
scope: APP_TOOLS["app.list"].scope,
|
|
27
|
+
description: APP_TOOLS["app.list"].description,
|
|
28
|
+
inputSchema: { environment: environmentArg, capability: capabilityArg },
|
|
29
|
+
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
30
|
+
const applications = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).map((row) => ({
|
|
31
|
+
code: String(row.appCode ?? row.code),
|
|
32
|
+
name: row.appName ?? row.name,
|
|
33
|
+
groupId: row.groupId,
|
|
34
|
+
}));
|
|
35
|
+
return { applications };
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
writeTool(tools, "app.create", APP_LANE, APP_TOOLS["app.create"].scope, APP_TOOLS["app.create"].description, { code: z.string().min(1), name: z.string().min(1), groupId: z.string().optional() }, async (session, args) => {
|
|
39
|
+
const existing = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).find((row) => (row.appCode ?? row.code) === args.code);
|
|
40
|
+
if (existing)
|
|
41
|
+
throw new ToolRefusalError({ status: "refused", blocker: "app-create-duplicate", code: args.code, message: `应用 ${args.code} 已存在;拒绝重复 POST,不能用写后存在证明本次创建` });
|
|
42
|
+
let groupId = args.groupId;
|
|
43
|
+
let resolvedFrom = args.groupId ? "explicit" : undefined;
|
|
44
|
+
let warning;
|
|
45
|
+
let groups = [];
|
|
46
|
+
const readGroups = async () => wireRows((await wireCall(session, { path: ENDPOINTS.applicationGroupList, query: {} })).data);
|
|
47
|
+
if (!groupId) {
|
|
48
|
+
const configured = session.profile.applicationCode?.trim();
|
|
49
|
+
const apps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data);
|
|
50
|
+
const configuredApp = configured ? apps.find((row) => (row.appCode ?? row.code) === configured) : undefined;
|
|
51
|
+
if (configuredApp && typeof configuredApp.groupId === "string" && configuredApp.groupId) {
|
|
52
|
+
groupId = configuredApp.groupId;
|
|
53
|
+
resolvedFrom = "auto-configured-app-group";
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
groups = await readGroups();
|
|
57
|
+
const withId = groups.filter((row) => typeof row.id === "string" && String(row.id));
|
|
58
|
+
// Prefer a named non-fallback group. The tenant normally has one (e.g.
|
|
59
|
+
// code=default); landing in the fallback group `all` makes the app
|
|
60
|
+
// portal-hidden, which the user cannot see and the create cannot undo
|
|
61
|
+
// without app.update.
|
|
62
|
+
const named = withId.find((row) => String(row.code ?? "").toLowerCase() !== "all");
|
|
63
|
+
const group = named ?? withId[0];
|
|
64
|
+
if (!group)
|
|
65
|
+
throw new ToolRefusalError({
|
|
66
|
+
status: "refused",
|
|
67
|
+
blocker: "app-create-prerequisite-missing",
|
|
68
|
+
preconditions: [{ code: "application-group-required", satisfied: false }],
|
|
69
|
+
recovery: [{ code: "application-group-missing", action: "由管理侧创建应用分组后重新 discover_environment" }],
|
|
70
|
+
message: "创建应用前置条件不满足:租户没有可用应用分组;零 create wire",
|
|
71
|
+
});
|
|
72
|
+
groupId = String(group.id);
|
|
73
|
+
resolvedFrom = "auto-first-group";
|
|
74
|
+
const fallback = String(group.code ?? "").toLowerCase() === "all";
|
|
75
|
+
warning = fallback ? "resolved-to-fallback-group; portalHidden:true" : "resolved-to-first-group";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (groups.length === 0)
|
|
79
|
+
groups = await readGroups();
|
|
80
|
+
const selectedGroup = groups.find((row) => String(row.id ?? "") === String(groupId));
|
|
81
|
+
if (!selectedGroup)
|
|
82
|
+
throw new ToolRefusalError({
|
|
83
|
+
status: "refused",
|
|
84
|
+
blocker: "app-create-prerequisite-missing",
|
|
85
|
+
groupId,
|
|
86
|
+
preconditions: [{ code: "application-group-required", satisfied: false, groupId }],
|
|
87
|
+
recovery: [{ code: "application-group-missing", action: "改用 discover_environment 返回的有效 groupId,或由管理侧修复分组" }],
|
|
88
|
+
message: `创建应用前置条件不满足:分组 ${String(groupId)} 无法解析;零 create wire`,
|
|
89
|
+
});
|
|
90
|
+
const performed = await wireCall(session, {
|
|
91
|
+
path: ENDPOINTS.applicationCreate,
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: {
|
|
94
|
+
groupId,
|
|
95
|
+
code: args.code,
|
|
96
|
+
name: args.name,
|
|
97
|
+
name_i18n: JSON.stringify({ "zh-CN": args.name }),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const present = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).some((row) => (row.appCode ?? row.code) === args.code);
|
|
101
|
+
// Portal visibility is a DERIVED FACT (group membership), reported on
|
|
102
|
+
// every readback so a caller never has to infer it from `warning` and
|
|
103
|
+
// never reports `ok` while claiming visibility it did not verify.
|
|
104
|
+
const portalHidden = isFallbackGroup(selectedGroup);
|
|
105
|
+
return {
|
|
106
|
+
performed: { errcode: performed.errcode, groupId, resolvedFrom, selectedGroup: { id: selectedGroup.id, code: selectedGroup.code, name: selectedGroup.name } },
|
|
107
|
+
readback: {
|
|
108
|
+
present,
|
|
109
|
+
selectedGroup: { id: selectedGroup.id, code: selectedGroup.code, name: selectedGroup.name },
|
|
110
|
+
postconditions: { created: present, published: false, portalVisible: !portalHidden, portalHidden },
|
|
111
|
+
portalHidden,
|
|
112
|
+
...(warning ? { warning } : {}),
|
|
113
|
+
publishBoundary: { inScope: false, nextAction: "publish-in-designer", reason: "app publish is outside the CLI configuration boundary" },
|
|
114
|
+
recovery: [{ code: "publish-in-designer", action: "在设计器发布应用并核对其位于具名分组;再做门户可见性验证" }],
|
|
115
|
+
note: "应用发布属边界外:建成后需人工在设计器发布并核对该分组,否则门户不可见",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
writeTool(tools, "app.update", APP_LANE, APP_TOOLS["app.update"].scope, APP_TOOLS["app.update"].description, { code: z.string().min(1), groupId: z.string().min(1) }, async (session, args) => {
|
|
120
|
+
const record = (await wireCall(session, { path: ENDPOINTS.applicationGet, query: { appCode: args.code } })).data;
|
|
121
|
+
if (!record || (typeof record === "object" && Object.keys(record).length === 0))
|
|
122
|
+
return { performed: { skipped: "already-absent" }, readback: { absent: true } };
|
|
123
|
+
const previousGroupId = typeof record.groupId === "string" ? record.groupId : null;
|
|
124
|
+
const performed = await wireCall(session, {
|
|
125
|
+
path: ENDPOINTS.applicationUpdate,
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: {
|
|
128
|
+
...record,
|
|
129
|
+
groupId: args.groupId,
|
|
130
|
+
enabled: record.enabled,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
// Two independent reads: apppackage/list rows carry `published` (and the
|
|
134
|
+
// group), while the whole-record `get` carries `enabled`. Merge them so
|
|
135
|
+
// the readback can assert portal visibility without a second caller round.
|
|
136
|
+
const listRow = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).find((row) => (row.appCode ?? row.code) === args.code);
|
|
137
|
+
const groupRow = wireRows((await wireCall(session, { path: ENDPOINTS.applicationGroupList, query: {} })).data).find((row) => String(row.id ?? "") === String(args.groupId));
|
|
138
|
+
const after = (await wireCall(session, { path: ENDPOINTS.applicationGet, query: { appCode: args.code } })).data;
|
|
139
|
+
const portalHiddenByGroup = groupRow === undefined || isFallbackGroup(groupRow);
|
|
140
|
+
// `groupChanged` must reflect a REAL change: comparing the stored value
|
|
141
|
+
// against the requested one — an update that lands on the same group is
|
|
142
|
+
// not a change, and reporting it as one made the flag untrustworthy
|
|
143
|
+
// (dogfood R3: groupChanged:true while previousGroupId === groupId).
|
|
144
|
+
const storedGroupId = typeof after?.groupId === "string" ? after.groupId : listRow?.groupId ?? null;
|
|
145
|
+
const groupChanged = String(storedGroupId ?? "") !== String(previousGroupId ?? "");
|
|
146
|
+
return {
|
|
147
|
+
performed: { errcode: performed.errcode, code: args.code, groupId: args.groupId, previousGroupId },
|
|
148
|
+
readback: {
|
|
149
|
+
present: listRow !== undefined || after !== null,
|
|
150
|
+
groupId: storedGroupId,
|
|
151
|
+
previousGroupId,
|
|
152
|
+
groupChanged,
|
|
153
|
+
groupCode: groupRow?.code ?? null,
|
|
154
|
+
published: listRow?.published === true,
|
|
155
|
+
portalHidden: portalHiddenByGroup,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
writeTool(tools, "app.delete", APP_LANE, APP_TOOLS["app.delete"].scope, APP_TOOLS["app.delete"].description, { code: z.string().min(1) }, async (session, args) => {
|
|
160
|
+
const readApps = async () => wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data);
|
|
161
|
+
const target = (await readApps()).find((row) => (row.appCode ?? row.code) === args.code);
|
|
162
|
+
if (!target)
|
|
163
|
+
return { performed: { skipped: "already-absent" }, readback: { absent: true } };
|
|
164
|
+
const performed = await wireCall(session, { path: ENDPOINTS.applicationDelete, method: "POST", query: { appCode: args.code } });
|
|
165
|
+
let absent = false;
|
|
166
|
+
for (let attempt = 0; attempt < 12 && !absent; attempt += 1) {
|
|
167
|
+
if (attempt > 0)
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(1000 * attempt, 3000)));
|
|
169
|
+
absent = !(await readApps()).some((row) => (row.appCode ?? row.code) === args.code);
|
|
170
|
+
}
|
|
171
|
+
return { performed: { errcode: performed.errcode, code: args.code }, readback: { absent } };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=app.js.map
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { confirmProductionArg, environmentArg, WRITE_PROTOCOL } from "../args.js";
|
|
4
|
+
import { executePackage } from "../package.js";
|
|
5
|
+
import { plannableArgs } from "../plan.js";
|
|
6
|
+
import { isUncertainWireError, wireErrorKind, writeArtifact } from "../support.js";
|
|
7
|
+
import { defineTool, parseToolInput, ToolInputError, ToolRefusalError } from "../tool-types.js";
|
|
8
|
+
import { WireError } from "../wire.js";
|
|
9
|
+
/**
|
|
10
|
+
* Ticket 58: the batch time budget. `startedAt` is the execution start (ms
|
|
11
|
+
* epoch); `budgetMs` undefined means no budget. Exported so the arithmetic is
|
|
12
|
+
* pinned by a unit test instead of a timing-sensitive S1 test.
|
|
13
|
+
*/
|
|
14
|
+
export function batchBudgetExceeded(startedAt, budgetMs, now) {
|
|
15
|
+
if (budgetMs === undefined)
|
|
16
|
+
return false;
|
|
17
|
+
return now - startedAt >= budgetMs;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The default whole-batch time budget (8 minutes), applied when the caller
|
|
21
|
+
* omits `totalTimeoutMs`. It MUST stay safely below the CLI's own call timeout:
|
|
22
|
+
* the omc CLI caps `--timeout-ms` at 600000 ms (10 min), and the 2026-09-14
|
|
23
|
+
* `eq_repair_record` incident was exactly the failure mode of a batch that
|
|
24
|
+
* outlives its caller — the caller abandoned at its timeout while the batch
|
|
25
|
+
* kept executing, and the agent's retry became a second writer in the same
|
|
26
|
+
* environment. Stopping at a bounded budget returns a clean `uncertain` +
|
|
27
|
+
* progressFile + resumeFromIndex while the caller is still listening.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_BATCH_BUDGET_MS = 8 * 60 * 1000;
|
|
30
|
+
function refuseBatch(blocker, message, extra = {}) {
|
|
31
|
+
return { status: "refused", blocker, message, ...extra };
|
|
32
|
+
}
|
|
33
|
+
export function registerApplyChangesLane(tools) {
|
|
34
|
+
// Registered LAST in tools.ts, so the array reference is the complete
|
|
35
|
+
// registry by the time any handler runs.
|
|
36
|
+
const siblings = tools;
|
|
37
|
+
tools.push(defineTool({
|
|
38
|
+
name: "apply_changes",
|
|
39
|
+
layer: "high",
|
|
40
|
+
kind: "write",
|
|
41
|
+
description: `批量写入:changes:[{tool,args}](仅低层原子工具,configure_* 拒收);一次权限检查、按序读回、首败即停;长批量回传 progressFile(totalTimeoutMs 设整批预算)。 ${WRITE_PROTOCOL}`,
|
|
42
|
+
inputSchema: {
|
|
43
|
+
environment: environmentArg,
|
|
44
|
+
changes: z
|
|
45
|
+
.array(z.object({ tool: z.string().min(1), args: z.record(z.string(), z.unknown()).default({}) }))
|
|
46
|
+
.min(1)
|
|
47
|
+
.describe("有序变更清单;每项 { tool: 既有低层写工具名, args: 其参数 };整包共用一次审批"),
|
|
48
|
+
summary: z.string().optional().describe("可选的整体业务描述,进入审批卡片与返回的 summary"),
|
|
49
|
+
totalTimeoutMs: z.number().int().positive().optional().describe(`整批时间预算(毫秒,自执行开始计);超预算即停并返回已完成步骤/progressFile,status:uncertain、blocker:batch-timeout-budget。缺省 ${8 * 60 * 1000}ms(低于 CLI 调用超时,保证调用方仍在监听时干净返回);长批量建议显式设置并分批`),
|
|
50
|
+
confirmProduction: confirmProductionArg,
|
|
51
|
+
dryRun: z.boolean().optional().describe("true = 只返回整包预览(plan/actions + planFingerprint),零环境写入"),
|
|
52
|
+
},
|
|
53
|
+
handler: async (ctx, args) => {
|
|
54
|
+
const changes = args.changes;
|
|
55
|
+
// ---- 1. Structural validation (zero wire calls) --------------------
|
|
56
|
+
if (!Array.isArray(changes) || changes.length === 0)
|
|
57
|
+
return refuseBatch("apply-changes-invalid", "changes 必须是非空数组(每项 { tool, args })");
|
|
58
|
+
const targets = new Map();
|
|
59
|
+
const normalizedChanges = [];
|
|
60
|
+
for (const change of changes) {
|
|
61
|
+
if (!(change.tool && typeof change.tool === "string"))
|
|
62
|
+
return refuseBatch("apply-changes-invalid", "changes 每项必须包含 tool");
|
|
63
|
+
if (!targets.has(change.tool)) {
|
|
64
|
+
const target = siblings.find((tool) => tool.name === change.tool);
|
|
65
|
+
if (!target)
|
|
66
|
+
return refuseBatch("apply-changes-unknown-tool", `changes 引用了不存在的工具 ${change.tool}`, { tool: change.tool });
|
|
67
|
+
if (target.kind !== "write")
|
|
68
|
+
return refuseBatch("apply-changes-tool-not-writable", `${change.tool} 不是写工具(changes 只接受写操作)`, { tool: change.tool });
|
|
69
|
+
if (target.layer !== "low" || !target.writeExecute)
|
|
70
|
+
return refuseBatch("apply-changes-tool-not-atomic", `${change.tool} 是高层预设(已是单调用包);changes 只接受低层原子写工具`, { tool: change.tool });
|
|
71
|
+
if (!target.lane || !target.scope)
|
|
72
|
+
return refuseBatch("apply-changes-tool-not-atomic", `${change.tool} 缺少 lane/scope 声明,无法通过覆盖门`, { tool: change.tool });
|
|
73
|
+
targets.set(change.tool, target);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
normalizedChanges.push({ tool: change.tool, args: parseToolInput(targets.get(change.tool), change.args) });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof ToolInputError)
|
|
80
|
+
return { ...error.payload, failedBeforeApproval: true, changeTool: change.tool };
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// ---- 2. Semantic gates BEFORE the write permission gate (dry) ------
|
|
85
|
+
try {
|
|
86
|
+
for (const change of normalizedChanges)
|
|
87
|
+
targets.get(change.tool).writePreflight?.(change.args);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof ToolRefusalError)
|
|
91
|
+
return { ...error.payload, failedBeforeApproval: true };
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
// ---- 3. One plan, one fingerprint, per-action coverage ------------
|
|
95
|
+
const toolPhrase = (list) => {
|
|
96
|
+
const counts = new Map();
|
|
97
|
+
for (const change of list)
|
|
98
|
+
counts.set(change.tool, (counts.get(change.tool) ?? 0) + 1);
|
|
99
|
+
return [...counts.entries()].map(([tool, count]) => (count > 1 ? `${tool}×${count}` : tool)).join("、");
|
|
100
|
+
};
|
|
101
|
+
const plan = {
|
|
102
|
+
environment: "",
|
|
103
|
+
environmentFingerprint: "",
|
|
104
|
+
summary: args.summary ?? `即将按序执行 ${normalizedChanges.length} 项变更(${toolPhrase(normalizedChanges)})`,
|
|
105
|
+
actions: normalizedChanges.map((change) => ({ tool: change.tool, args: plannableArgs(change.tool, change.args) })),
|
|
106
|
+
};
|
|
107
|
+
const coverage = {
|
|
108
|
+
perAction: normalizedChanges.map((change) => {
|
|
109
|
+
const target = targets.get(change.tool);
|
|
110
|
+
return { lane: target.lane, scope: target.scope };
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
return executePackage(ctx, plan, coverage, args, async (session) => {
|
|
114
|
+
const steps = [];
|
|
115
|
+
// Ticket 58: a recoverable handle for a long batch. Every step appends
|
|
116
|
+
// to one progress artifact, so a client-side timeout or a killed
|
|
117
|
+
// server still leaves the exact progress on disk.
|
|
118
|
+
const runId = randomUUID().slice(0, 8);
|
|
119
|
+
const startedAt = Date.now();
|
|
120
|
+
const budgetMs = args.totalTimeoutMs ?? DEFAULT_BATCH_BUDGET_MS;
|
|
121
|
+
const batchSummary = plan.summary;
|
|
122
|
+
const recordProgress = (status, upTo) => writeArtifact(ctx, `batch-progress-${runId}.json`, {
|
|
123
|
+
runId,
|
|
124
|
+
summary: batchSummary,
|
|
125
|
+
startedAt,
|
|
126
|
+
budgetMs: budgetMs ?? null,
|
|
127
|
+
steps,
|
|
128
|
+
nextIndex: upTo,
|
|
129
|
+
status,
|
|
130
|
+
}).detailFile;
|
|
131
|
+
for (let index = 0; index < normalizedChanges.length; index += 1) {
|
|
132
|
+
const change = normalizedChanges[index];
|
|
133
|
+
const target = targets.get(change.tool);
|
|
134
|
+
if (batchBudgetExceeded(startedAt, budgetMs, Date.now())) {
|
|
135
|
+
const progressFile = recordProgress("STOPPED", steps.length);
|
|
136
|
+
const stop = {
|
|
137
|
+
status: "uncertain",
|
|
138
|
+
steps,
|
|
139
|
+
failedStep: { index, tool: change.tool },
|
|
140
|
+
envelope: {
|
|
141
|
+
blocker: "batch-timeout-budget",
|
|
142
|
+
frozen: true,
|
|
143
|
+
budgetMs,
|
|
144
|
+
message: `已达整批预算 ${budgetMs}ms,在第 ${index + 1} 步(${change.tool})前停止;已完成 ${steps.length} 项(其效果真实存在)。先读 progressFile 与只读发现确认进度,再决定是否续跑——绝不盲重试`,
|
|
145
|
+
summary: `${args.summary ? `${args.summary};` : ""}批量达预算停止于第 ${index + 1} 步前,已完成 ${steps.length} 项`,
|
|
146
|
+
progressFile,
|
|
147
|
+
// Ticket 81: structured recovery semantics — a timeout is NOT
|
|
148
|
+
// failure; the server may have completed everything.
|
|
149
|
+
nextAction: {
|
|
150
|
+
rule: "read-back-first",
|
|
151
|
+
steps: [
|
|
152
|
+
"读 progressFile:拿到已完成步骤清单(其效果真实存在于环境)",
|
|
153
|
+
"用只读发现核对每一步的权威面(discover_environment / 对应 lane 的 list/get 读回;写工具的 already-present/already-published 短路可安全复用)",
|
|
154
|
+
`仅对缺失步骤重新组包:从 failedStep.index=${index} 起,剔除读回确认已生效的项`,
|
|
155
|
+
],
|
|
156
|
+
forbidden: "盲目整批重试——已完成步骤会重复执行或触发重复码拒绝(如 listview.create 303002)",
|
|
157
|
+
resumeFromIndex: index,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return stop;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const outcome = await target.writeExecute(session, change.args);
|
|
165
|
+
steps.push({ step: change.tool, index, status: "ok", performed: outcome.performed, readback: outcome.readback });
|
|
166
|
+
recordProgress("RUNNING", index + 1);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const failedStep = { index, tool: change.tool };
|
|
170
|
+
if (isUncertainWireError(error)) {
|
|
171
|
+
// Per-step transport/timeout: uncertain, frozen — never success.
|
|
172
|
+
const progressFile = recordProgress("STOPPED", steps.length);
|
|
173
|
+
const stop = {
|
|
174
|
+
status: "uncertain",
|
|
175
|
+
steps,
|
|
176
|
+
failedStep,
|
|
177
|
+
envelope: {
|
|
178
|
+
frozen: true,
|
|
179
|
+
reason: `wire-transport:${wireErrorKind(error)}`,
|
|
180
|
+
detail: error.message,
|
|
181
|
+
message: `第 ${index + 1} 步(${change.tool})传输中断,写入结果无法判定;已冻结——先重新发现再决定重试,绝不假定为成功`,
|
|
182
|
+
summary: `${args.summary ? `${args.summary};` : ""}批量在第 ${index + 1} 步(${change.tool})中断,已完成 ${steps.length} 项,结果不确定`,
|
|
183
|
+
progressFile,
|
|
184
|
+
// Ticket 81: the interrupted step itself may have taken
|
|
185
|
+
// effect server-side — read back before any retry.
|
|
186
|
+
nextAction: {
|
|
187
|
+
rule: "read-back-first",
|
|
188
|
+
steps: [
|
|
189
|
+
"读 progressFile:拿到已完成步骤清单(其效果真实存在于环境)",
|
|
190
|
+
`先核对第 ${index + 1} 步(${change.tool})本身是否已生效——超时≠失败,服务端可能已写入`,
|
|
191
|
+
"用只读发现核对每一步的权威面,仅对缺失步骤重新组包执行",
|
|
192
|
+
],
|
|
193
|
+
forbidden: "盲目整批重试或假定中断步骤失败——重复执行会撞重复码(如 listview.create 303002)",
|
|
194
|
+
resumeFromIndex: index,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
return stop;
|
|
199
|
+
}
|
|
200
|
+
const envelope = error instanceof ToolRefusalError
|
|
201
|
+
? { ...error.payload }
|
|
202
|
+
: error instanceof WireError
|
|
203
|
+
? { blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message }
|
|
204
|
+
: { blocker: "apply-changes-step-failed", message: error.message };
|
|
205
|
+
const progressFile = recordProgress("STOPPED", steps.length);
|
|
206
|
+
const stop = {
|
|
207
|
+
status: "refused",
|
|
208
|
+
steps,
|
|
209
|
+
failedStep,
|
|
210
|
+
envelope: {
|
|
211
|
+
...envelope,
|
|
212
|
+
message: `${typeof envelope.message === "string" ? envelope.message : "步骤失败"};批量在第 ${index + 1} 步(${change.tool})停止,后续步骤未执行;已完成步骤见 steps(其效果真实存在,需要善后时按 steps 清单处理)`,
|
|
213
|
+
summary: `${args.summary ? `${args.summary};` : ""}批量在第 ${index + 1} 步(${change.tool})停止,已完成 ${steps.length} 项`,
|
|
214
|
+
progressFile,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
return stop;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const executedTools = [...new Set(normalizedChanges.map((change) => change.tool))];
|
|
221
|
+
const progressFile = recordProgress("DONE", steps.length);
|
|
222
|
+
return {
|
|
223
|
+
steps,
|
|
224
|
+
readback: { stepCount: steps.length, tools: executedTools, allStepsOk: steps.every((step) => step.status === "ok"), progressFile },
|
|
225
|
+
summary: args.summary ?? `已按序完成 ${steps.length} 项变更(${toolPhrase(changes)})`,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=apply-changes.js.map
|