

















@@ -108,6 +108,89 @@ function getGatewayRetryAfterMs(error: unknown) {
108108return null;
109109}
110110111+function isPlainObject(value: unknown): value is Record<string, unknown> {
112+return typeof value === "object" && value !== null && !Array.isArray(value);
113+}
114+115+function isObjectWithStringId(value: unknown): value is { id: string } & Record<string, unknown> {
116+return isPlainObject(value) && typeof value.id === "string";
117+}
118+119+function applyQaMergePatch(target: unknown, patch: unknown): unknown {
120+if (Array.isArray(target) && Array.isArray(patch)) {
121+const merged = target.map((entry) => structuredClone(entry));
122+const indexById = new Map<string, number>();
123+for (const [index, entry] of merged.entries()) {
124+if (isObjectWithStringId(entry)) {
125+indexById.set(entry.id, index);
126+}
127+}
128+for (const patchEntry of patch) {
129+if (!isObjectWithStringId(patchEntry)) {
130+merged.push(structuredClone(patchEntry));
131+continue;
132+}
133+const existingIndex = indexById.get(patchEntry.id);
134+if (existingIndex === undefined) {
135+merged.push(structuredClone(patchEntry));
136+indexById.set(patchEntry.id, merged.length - 1);
137+continue;
138+}
139+merged[existingIndex] = applyQaMergePatch(merged[existingIndex], patchEntry);
140+}
141+return merged;
142+}
143+if (!isPlainObject(patch)) {
144+return structuredClone(patch);
145+}
146+const base = isPlainObject(target) ? structuredClone(target) : {};
147+for (const [key, value] of Object.entries(patch)) {
148+if (value === null) {
149+delete base[key];
150+continue;
151+}
152+base[key] = applyQaMergePatch(base[key], value);
153+}
154+return base;
155+}
156+157+function areJsonValuesEqual(left: unknown, right: unknown): boolean {
158+if (Object.is(left, right)) {
159+return true;
160+}
161+if (Array.isArray(left) || Array.isArray(right)) {
162+if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
163+return false;
164+}
165+return left.every((entry, index) => areJsonValuesEqual(entry, right[index]));
166+}
167+if (isPlainObject(left) || isPlainObject(right)) {
168+if (!isPlainObject(left) || !isPlainObject(right)) {
169+return false;
170+}
171+const leftKeys = Object.keys(left).toSorted();
172+const rightKeys = Object.keys(right).toSorted();
173+if (!areJsonValuesEqual(leftKeys, rightKeys)) {
174+return false;
175+}
176+return leftKeys.every((key) => areJsonValuesEqual(left[key], right[key]));
177+}
178+return false;
179+}
180+181+function isConfigPatchNoopForSnapshot(config: Record<string, unknown>, raw: string): boolean {
182+let patch: unknown;
183+try {
184+patch = JSON.parse(raw);
185+} catch {
186+return false;
187+}
188+if (!isPlainObject(patch)) {
189+return false;
190+}
191+return areJsonValuesEqual(applyQaMergePatch(config, patch), config);
192+}
193+111194async function readConfigSnapshot(env: Pick<QaSuiteRuntimeEnv, "gateway">) {
112195const snapshot = (await env.gateway.call(
113196"config.get",
@@ -141,6 +224,15 @@ async function runConfigMutation(params: {
141224let lastConflict: unknown = null;
142225for (let attempt = 1; attempt <= 8; attempt += 1) {
143226const snapshot = await readConfigSnapshot(params.env);
227+if (
228+params.action === "config.patch" &&
229+isConfigPatchNoopForSnapshot(snapshot.config, params.raw)
230+) {
231+// QA scenarios do best-effort cleanup in finally blocks. Skipping
232+// client-known no-op patches keeps that cleanup from burning the
233+// control-plane write budget and making later capability checks flaky.
234+return { ok: true, noop: true };
235+}
144236try {
145237const result = await params.env.gateway.call(
146238params.action,
@@ -235,6 +327,7 @@ export {
235327fetchJson,
236328formatGatewayPrimaryErrorText,
237329getGatewayRetryAfterMs,
330+isConfigPatchNoopForSnapshot,
238331isConfigHashConflict,
239332isGatewayRestartRace,
240333patchConfig,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。