pi-cache-optimizer 2.8.16 → 2.8.17
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 +19 -17
- package/README.zh-CN.md +19 -17
- package/index.ts +246 -11409
- package/package.json +8 -6
- package/src/adapters.ts +250 -0
- package/src/atomic-fs.ts +300 -0
- package/src/command-completion.ts +114 -0
- package/src/command.ts +1012 -0
- package/src/common.ts +49 -0
- package/src/compat-advice.ts +320 -0
- package/src/compat-config.ts +99 -0
- package/src/config.ts +374 -0
- package/src/diagnostics.ts +757 -0
- package/src/fix-types.ts +66 -0
- package/src/jsonc.ts +602 -0
- package/src/model-detect.ts +66 -0
- package/src/model-identity.ts +368 -0
- package/src/models-json-fix.ts +1982 -0
- package/src/paths.ts +18 -0
- package/src/prompt-cache-key-config.ts +406 -0
- package/src/prompt-rewrite.ts +294 -0
- package/src/provider-errors.ts +328 -0
- package/src/request-payload.ts +197 -0
- package/src/request-state.ts +102 -0
- package/src/retention.ts +82 -0
- package/src/routing.ts +551 -0
- package/src/stats-report.ts +771 -0
- package/src/stats-store.ts +500 -0
- package/src/tool-ordering.ts +209 -0
- package/src/usage.ts +172 -0
package/src/paths.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Use Pi core's resolver so rebranded config names and env semantics stay aligned. */
|
|
5
|
+
export const STATE_DIR = getAgentDir();
|
|
6
|
+
|
|
7
|
+
export const FIX_RECEIPT_FILE_NAME = "pi-cache-optimizer-fix-receipt.json";
|
|
8
|
+
|
|
9
|
+
export const FIX_RECEIPT_PATH = join(STATE_DIR, FIX_RECEIPT_FILE_NAME);
|
|
10
|
+
|
|
11
|
+
export const MODELS_TRANSACTION_LOCK_PATH = join(STATE_DIR, "pi-cache-optimizer-models-transaction.lock");
|
|
12
|
+
|
|
13
|
+
export const MODELS_TRANSACTION_LOCK_STALE_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
export const MODELS_TRANSACTION_LOCK_WAIT_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
/** The real models.json path used for I/O. */
|
|
18
|
+
export const MODELS_JSON_PATH = join(STATE_DIR, "models.json");
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { type FileIdentity, atomicCreateTextFileNoReplace, atomicReplaceTextFilePreservingMode, atomicRestoreFileFromBackup, backupTimestamp, hashText, sameFileIdentity, uniqueTempPath, validateAtomicTarget, withModelsJsonTransactionLock } from "./atomic-fs.ts";
|
|
2
|
+
import { LOG_PREFIX, type PiModel, asRecord, getErrorCode } from "./common.ts";
|
|
3
|
+
import { CONFIG_FILE_PATH, type PersistedCacheOptimizerConfigV2, type PersistedCacheOptimizerConfigV3, normalizePersistedCacheOptimizerConfig, parsePersistedCacheOptimizerConfig } from "./config.ts";
|
|
4
|
+
import { isReceiptTimestamp, isSafeReceiptText, isSha256 } from "./fix-types.ts";
|
|
5
|
+
import { modelKey } from "./model-identity.ts";
|
|
6
|
+
import { STATE_DIR } from "./paths.ts";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { constants as fsConstants } from "node:fs";
|
|
9
|
+
import { chmod, copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const CONFIG_RECEIPT_FILE_NAME = "pi-cache-optimizer-config-receipt.json";
|
|
13
|
+
|
|
14
|
+
export const CONFIG_RECEIPT_PATH = join(STATE_DIR, CONFIG_RECEIPT_FILE_NAME);
|
|
15
|
+
|
|
16
|
+
export type PromptCacheKeyConfigReceipt = {
|
|
17
|
+
version: 2;
|
|
18
|
+
kind: "pi-cache-optimizer-config-receipt";
|
|
19
|
+
transactionId: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
modelId: string;
|
|
22
|
+
beforeHash: string;
|
|
23
|
+
afterHash: string;
|
|
24
|
+
backupFile: string;
|
|
25
|
+
targetExistedBefore: boolean;
|
|
26
|
+
targetHadModelKey: boolean;
|
|
27
|
+
addedModelKey: string;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
appliedAt: number;
|
|
30
|
+
status?: "rolled_back";
|
|
31
|
+
rolledBackAt?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type PromptCacheKeyConfigReceiptSnapshot = {
|
|
35
|
+
receipt: PromptCacheKeyConfigReceipt;
|
|
36
|
+
receiptPath: string;
|
|
37
|
+
hash: string;
|
|
38
|
+
identity: FileIdentity;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function configReceiptBackupPath(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): string {
|
|
42
|
+
return join(dirname(receiptPath), receipt.backupFile);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parsePromptCacheKeyConfigReceipt(value: unknown): PromptCacheKeyConfigReceipt | undefined {
|
|
46
|
+
const record = asRecord(value);
|
|
47
|
+
if (!record || (record.version !== 1 && record.version !== 2) || record.kind !== "pi-cache-optimizer-config-receipt") return undefined;
|
|
48
|
+
const allowed = new Set(record.version === 1
|
|
49
|
+
? ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "createdAt", "appliedAt", "status", "rolledBackAt"]
|
|
50
|
+
: ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "targetHadModelKey", "addedModelKey", "createdAt", "appliedAt", "status", "rolledBackAt"]);
|
|
51
|
+
if (Object.keys(record).some((key) => !allowed.has(key))) return undefined;
|
|
52
|
+
if (![record.transactionId, record.provider, record.modelId].every(isSafeReceiptText)) return undefined;
|
|
53
|
+
if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
|
|
54
|
+
if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || !record.backupFile.startsWith("pi-cache-optimizer-config.backup-")) return undefined;
|
|
55
|
+
if (
|
|
56
|
+
typeof record.targetExistedBefore !== "boolean" ||
|
|
57
|
+
!isReceiptTimestamp(record.createdAt) ||
|
|
58
|
+
!isReceiptTimestamp(record.appliedAt) ||
|
|
59
|
+
record.appliedAt < record.createdAt
|
|
60
|
+
) return undefined;
|
|
61
|
+
const addedModelKey = `${record.provider}/${record.modelId}`;
|
|
62
|
+
const targetHadModelKey = record.version === 1 ? false : record.targetHadModelKey;
|
|
63
|
+
if (typeof targetHadModelKey !== "boolean") return undefined;
|
|
64
|
+
if (record.version === 2 && (!isSafeReceiptText(record.addedModelKey) || record.addedModelKey !== addedModelKey)) return undefined;
|
|
65
|
+
if (record.status !== undefined && record.status !== "rolled_back") return undefined;
|
|
66
|
+
if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
|
|
67
|
+
if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
|
|
68
|
+
return {
|
|
69
|
+
version: 2,
|
|
70
|
+
kind: "pi-cache-optimizer-config-receipt",
|
|
71
|
+
transactionId: String(record.transactionId),
|
|
72
|
+
provider: String(record.provider),
|
|
73
|
+
modelId: String(record.modelId),
|
|
74
|
+
beforeHash: String(record.beforeHash).toLowerCase(),
|
|
75
|
+
afterHash: String(record.afterHash).toLowerCase(),
|
|
76
|
+
backupFile: String(record.backupFile),
|
|
77
|
+
targetExistedBefore: record.targetExistedBefore,
|
|
78
|
+
targetHadModelKey,
|
|
79
|
+
addedModelKey,
|
|
80
|
+
createdAt: Number(record.createdAt),
|
|
81
|
+
appliedAt: Number(record.appliedAt),
|
|
82
|
+
...(record.status === "rolled_back" ? { status: "rolled_back", rolledBackAt: Number(record.rolledBackAt) } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function assertPromptCacheKeyConfigReceiptSnapshotUnchanged(
|
|
87
|
+
snapshot: PromptCacheKeyConfigReceiptSnapshot,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const info = await lstat(snapshot.receiptPath);
|
|
90
|
+
if (info.isSymbolicLink() || !info.isFile() || !sameFileIdentity(snapshot.identity, info)) {
|
|
91
|
+
throw new Error("prompt-cache-key receipt changed since the rollback preview");
|
|
92
|
+
}
|
|
93
|
+
const text = await readFile(snapshot.receiptPath, "utf8");
|
|
94
|
+
const afterRead = await lstat(snapshot.receiptPath);
|
|
95
|
+
if (
|
|
96
|
+
afterRead.isSymbolicLink() ||
|
|
97
|
+
!afterRead.isFile() ||
|
|
98
|
+
!sameFileIdentity(info, afterRead) ||
|
|
99
|
+
hashText(text) !== snapshot.hash
|
|
100
|
+
) {
|
|
101
|
+
throw new Error("prompt-cache-key receipt changed since the rollback preview");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function writePromptCacheKeyConfigReceipt(
|
|
106
|
+
receipt: PromptCacheKeyConfigReceipt,
|
|
107
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
108
|
+
expectedSnapshot?: PromptCacheKeyConfigReceiptSnapshot,
|
|
109
|
+
/** Test-only race injector; production callers leave this undefined. */
|
|
110
|
+
beforeRename?: () => Promise<void>,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
if (!parsePromptCacheKeyConfigReceipt(receipt)) throw new Error("invalid prompt cache key config receipt");
|
|
113
|
+
if (expectedSnapshot?.receiptPath !== undefined && expectedSnapshot.receiptPath !== receiptPath) {
|
|
114
|
+
throw new Error("prompt-cache-key receipt path changed since the rollback preview");
|
|
115
|
+
}
|
|
116
|
+
if (expectedSnapshot) await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(expectedSnapshot);
|
|
117
|
+
await mkdir(dirname(receiptPath), { recursive: true });
|
|
118
|
+
let existingReceiptInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
119
|
+
try {
|
|
120
|
+
const info = await lstat(receiptPath);
|
|
121
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("invalid prompt-cache-key receipt path");
|
|
122
|
+
existingReceiptInfo = info;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
125
|
+
}
|
|
126
|
+
const tempPath = uniqueTempPath(receiptPath, "config-receipt");
|
|
127
|
+
try {
|
|
128
|
+
await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
129
|
+
const tempInfo = await lstat(tempPath);
|
|
130
|
+
if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary prompt-cache-key receipt");
|
|
131
|
+
await chmod(tempPath, 0o600);
|
|
132
|
+
const assertDestinationUnchanged = async (): Promise<void> => {
|
|
133
|
+
try {
|
|
134
|
+
const currentInfo = await lstat(receiptPath);
|
|
135
|
+
if (!existingReceiptInfo || !sameFileIdentity(existingReceiptInfo, currentInfo)) {
|
|
136
|
+
throw new Error("prompt-cache-key receipt changed during atomic write");
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (getErrorCode(error) !== "ENOENT" || existingReceiptInfo) throw error;
|
|
140
|
+
}
|
|
141
|
+
if (expectedSnapshot) await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(expectedSnapshot);
|
|
142
|
+
};
|
|
143
|
+
await assertDestinationUnchanged();
|
|
144
|
+
if (beforeRename) await beforeRename();
|
|
145
|
+
await assertDestinationUnchanged();
|
|
146
|
+
if (existingReceiptInfo) {
|
|
147
|
+
await rename(tempPath, receiptPath);
|
|
148
|
+
} else {
|
|
149
|
+
await link(tempPath, receiptPath);
|
|
150
|
+
await unlink(tempPath).catch((cleanupError) => {
|
|
151
|
+
console.warn(`${LOG_PREFIX}: committed prompt-cache-key receipt but failed to remove its temporary hard link`, cleanupError);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config receipt`, cleanupError); });
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function applyPromptCacheKeyConfigFixUnderLock(
|
|
161
|
+
model: PiModel,
|
|
162
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
163
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
164
|
+
): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
|
|
165
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
166
|
+
let originalText = "";
|
|
167
|
+
let targetExistedBefore = false;
|
|
168
|
+
let mode = 0o600;
|
|
169
|
+
try {
|
|
170
|
+
const info = await lstat(configPath);
|
|
171
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
|
|
172
|
+
originalText = await readFile(configPath, "utf8");
|
|
173
|
+
targetExistedBefore = true;
|
|
174
|
+
mode = info.mode & 0o7777;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
177
|
+
}
|
|
178
|
+
const parsedCurrent = targetExistedBefore ? parsePersistedCacheOptimizerConfig(JSON.parse(originalText)) : undefined;
|
|
179
|
+
if (targetExistedBefore && !parsedCurrent) throw new Error("optimizer config is invalid; no changes were made");
|
|
180
|
+
const current = normalizePersistedCacheOptimizerConfig(parsedCurrent);
|
|
181
|
+
const key = modelKey(model);
|
|
182
|
+
const existingOmit = current.promptCacheKey?.omit ?? [];
|
|
183
|
+
const targetHadModelKey = existingOmit.includes(key);
|
|
184
|
+
if (targetHadModelKey) throw new Error("prompt-cache-key is already configured; no changes were made");
|
|
185
|
+
const omit = [...new Set([...existingOmit, key])].sort();
|
|
186
|
+
// Serialize through the normalizer so a v3 config (with feature overrides) stays v3; writing
|
|
187
|
+
// `version: 2` next to `features` would make the whole file unreadable.
|
|
188
|
+
const next = normalizePersistedCacheOptimizerConfig({ ...current, promptCacheKey: { omit } });
|
|
189
|
+
const modifiedText = JSON.stringify(next, null, 2) + "\n";
|
|
190
|
+
const beforeHash = hashText(originalText);
|
|
191
|
+
const afterHash = hashText(modifiedText);
|
|
192
|
+
const originalInfo = targetExistedBefore ? await lstat(configPath) : undefined;
|
|
193
|
+
const backupFile = `pi-cache-optimizer-config.backup-${backupTimestamp()}`;
|
|
194
|
+
const backupPath = join(dirname(configPath), backupFile);
|
|
195
|
+
if (targetExistedBefore) {
|
|
196
|
+
await copyFile(configPath, backupPath, fsConstants.COPYFILE_EXCL);
|
|
197
|
+
await chmod(backupPath, mode);
|
|
198
|
+
}
|
|
199
|
+
const tempPath = uniqueTempPath(configPath, "config-fix");
|
|
200
|
+
let committedInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
201
|
+
try {
|
|
202
|
+
await writeFile(tempPath, modifiedText, { encoding: "utf8", mode, flag: "wx" });
|
|
203
|
+
await chmod(tempPath, mode);
|
|
204
|
+
if (originalInfo) {
|
|
205
|
+
await validateAtomicTarget(configPath, { identity: originalInfo, hash: beforeHash, mode });
|
|
206
|
+
} else {
|
|
207
|
+
try {
|
|
208
|
+
await lstat(configPath);
|
|
209
|
+
throw new Error("optimizer config appeared during the fix; no changes were made");
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (originalInfo) {
|
|
215
|
+
await rename(tempPath, configPath);
|
|
216
|
+
committedInfo = await lstat(configPath);
|
|
217
|
+
} else {
|
|
218
|
+
// Do not overwrite a config created after the absence check. Record the
|
|
219
|
+
// committed inode before cleaning up its temporary hard-link name so a
|
|
220
|
+
// cleanup failure can still compensate the config transaction.
|
|
221
|
+
await link(tempPath, configPath);
|
|
222
|
+
committedInfo = await lstat(configPath);
|
|
223
|
+
await unlink(tempPath).catch((cleanupError) => {
|
|
224
|
+
console.warn(`${LOG_PREFIX}: committed optimizer config but failed to remove its temporary hard link`, cleanupError);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const receipt: PromptCacheKeyConfigReceipt = {
|
|
228
|
+
version: 2,
|
|
229
|
+
kind: "pi-cache-optimizer-config-receipt",
|
|
230
|
+
transactionId: randomUUID(),
|
|
231
|
+
provider: model.provider,
|
|
232
|
+
modelId: model.id,
|
|
233
|
+
beforeHash,
|
|
234
|
+
afterHash,
|
|
235
|
+
backupFile,
|
|
236
|
+
targetExistedBefore,
|
|
237
|
+
targetHadModelKey,
|
|
238
|
+
addedModelKey: key,
|
|
239
|
+
createdAt: Date.now(),
|
|
240
|
+
appliedAt: Date.now(),
|
|
241
|
+
};
|
|
242
|
+
await writePromptCacheKeyConfigReceipt(receipt, receiptPath);
|
|
243
|
+
return { receipt, backupPath };
|
|
244
|
+
} catch (error) {
|
|
245
|
+
await unlink(tempPath).catch(() => {});
|
|
246
|
+
try {
|
|
247
|
+
if (targetExistedBefore && committedInfo) {
|
|
248
|
+
await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode });
|
|
249
|
+
} else if (!targetExistedBefore && committedInfo) {
|
|
250
|
+
await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode });
|
|
251
|
+
await unlink(configPath);
|
|
252
|
+
}
|
|
253
|
+
} catch (compensationError) {
|
|
254
|
+
const writeMessage = error instanceof Error ? error.message : String(error);
|
|
255
|
+
const compensationMessage = compensationError instanceof Error ? compensationError.message : String(compensationError);
|
|
256
|
+
throw new Error(`prompt-cache-key fix receipt update failed (${writeMessage}) and config compensation failed (${compensationMessage})`);
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function applyPromptCacheKeyConfigFix(
|
|
263
|
+
model: PiModel,
|
|
264
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
265
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
266
|
+
): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
|
|
267
|
+
return withModelsJsonTransactionLock(() => applyPromptCacheKeyConfigFixUnderLock(model, configPath, receiptPath));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type PromptCacheKeyRollbackOptions = {
|
|
271
|
+
/** Test-only race injector; production callers leave this undefined. */
|
|
272
|
+
beforeReceiptRename?: () => Promise<void>;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export async function rollbackPromptCacheKeyConfigUnderLock(
|
|
276
|
+
snapshot: PromptCacheKeyConfigReceiptSnapshot,
|
|
277
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
278
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
279
|
+
options: PromptCacheKeyRollbackOptions = {},
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
if (snapshot.receiptPath !== receiptPath) throw new Error("prompt-cache-key receipt path changed since the rollback preview");
|
|
282
|
+
await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(snapshot);
|
|
283
|
+
const receipt = snapshot.receipt;
|
|
284
|
+
const currentInfo = await lstat(configPath);
|
|
285
|
+
if (currentInfo.isSymbolicLink() || !currentInfo.isFile()) throw new Error("optimizer config is not a regular file; refusing to overwrite user changes");
|
|
286
|
+
const currentText = await readFile(configPath, "utf8");
|
|
287
|
+
const currentHash = hashText(currentText);
|
|
288
|
+
const currentMode = currentInfo.mode & 0o7777;
|
|
289
|
+
if (currentHash !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
|
|
290
|
+
const current = parsePersistedCacheOptimizerConfig(JSON.parse(currentText));
|
|
291
|
+
if (!current) throw new Error("optimizer config is invalid; refusing to overwrite user changes");
|
|
292
|
+
const key = `${receipt.provider}/${receipt.modelId}`;
|
|
293
|
+
if (receipt.addedModelKey !== key) throw new Error("prompt-cache-key receipt identity does not match; refusing to change user config");
|
|
294
|
+
if (receipt.targetHadModelKey) throw new Error("prompt-cache-key was already configured before this fix; refusing to remove user configuration");
|
|
295
|
+
const omit = current.version !== 1 ? current.promptCacheKey?.omit ?? [] : [];
|
|
296
|
+
if (!omit.includes(receipt.addedModelKey)) throw new Error("prompt-cache-key opt-out is no longer present; refusing to change user config");
|
|
297
|
+
|
|
298
|
+
let rollbackResultText: string | undefined;
|
|
299
|
+
let rollbackResultMode: number | undefined;
|
|
300
|
+
let rollbackResultInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
301
|
+
const backupPath = configReceiptBackupPath(receipt, receiptPath);
|
|
302
|
+
if (receipt.targetExistedBefore) {
|
|
303
|
+
const backupInfo = await lstat(backupPath);
|
|
304
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) throw new Error("config backup is not a regular file");
|
|
305
|
+
const backupText = await readFile(backupPath, "utf8");
|
|
306
|
+
if (hashText(backupText) !== receipt.beforeHash) throw new Error("config backup hash does not match the fix receipt");
|
|
307
|
+
rollbackResultText = backupText;
|
|
308
|
+
rollbackResultMode = backupInfo.mode & 0o7777;
|
|
309
|
+
await atomicRestoreFileFromBackup(
|
|
310
|
+
backupPath,
|
|
311
|
+
configPath,
|
|
312
|
+
rollbackResultMode,
|
|
313
|
+
{ backupHash: receipt.beforeHash, identity: currentInfo, hash: currentHash, mode: currentMode },
|
|
314
|
+
);
|
|
315
|
+
rollbackResultInfo = await lstat(configPath);
|
|
316
|
+
} else if (current.footerMode || omit.length > 1 || (current.version === 3 && current.features)) {
|
|
317
|
+
const remaining = omit.filter((item) => item !== receipt.addedModelKey);
|
|
318
|
+
const restored = normalizePersistedCacheOptimizerConfig({
|
|
319
|
+
...current,
|
|
320
|
+
promptCacheKey: remaining.length > 0 ? { omit: remaining } : undefined,
|
|
321
|
+
} as PersistedCacheOptimizerConfigV3);
|
|
322
|
+
rollbackResultText = JSON.stringify(restored, null, 2) + "\n";
|
|
323
|
+
rollbackResultMode = currentMode;
|
|
324
|
+
await atomicReplaceTextFilePreservingMode(
|
|
325
|
+
configPath,
|
|
326
|
+
rollbackResultText,
|
|
327
|
+
rollbackResultMode,
|
|
328
|
+
"config-rollback",
|
|
329
|
+
{ identity: currentInfo, hash: currentHash, mode: currentMode },
|
|
330
|
+
);
|
|
331
|
+
rollbackResultInfo = await lstat(configPath);
|
|
332
|
+
} else {
|
|
333
|
+
await validateAtomicTarget(configPath, { identity: currentInfo, hash: currentHash, mode: currentMode });
|
|
334
|
+
await unlink(configPath);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
await writePromptCacheKeyConfigReceipt(
|
|
339
|
+
{ ...receipt, status: "rolled_back", rolledBackAt: Date.now() },
|
|
340
|
+
receiptPath,
|
|
341
|
+
snapshot,
|
|
342
|
+
options.beforeReceiptRename,
|
|
343
|
+
);
|
|
344
|
+
} catch (receiptError) {
|
|
345
|
+
// Receipt marking is part of the transaction. If it fails after the config
|
|
346
|
+
// mutation, restore the exact post-fix config rather than leaving an
|
|
347
|
+
// actionable receipt paired with an already-rolled-back file.
|
|
348
|
+
try {
|
|
349
|
+
if (rollbackResultText === undefined) {
|
|
350
|
+
await atomicCreateTextFileNoReplace(configPath, currentText, currentMode, "config-rollback-compensation");
|
|
351
|
+
} else {
|
|
352
|
+
if (!rollbackResultInfo || rollbackResultMode === undefined) throw new Error("missing rollback result guard");
|
|
353
|
+
await atomicReplaceTextFilePreservingMode(
|
|
354
|
+
configPath,
|
|
355
|
+
currentText,
|
|
356
|
+
currentMode,
|
|
357
|
+
"config-rollback-compensation",
|
|
358
|
+
{
|
|
359
|
+
identity: rollbackResultInfo,
|
|
360
|
+
hash: hashText(rollbackResultText),
|
|
361
|
+
mode: rollbackResultMode,
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
} catch (compensationError) {
|
|
366
|
+
const receiptMessage = receiptError instanceof Error ? receiptError.message : String(receiptError);
|
|
367
|
+
const compensationMessage = compensationError instanceof Error ? compensationError.message : String(compensationError);
|
|
368
|
+
throw new Error(`prompt-cache-key rollback receipt update failed (${receiptMessage}) and config compensation failed (${compensationMessage})`);
|
|
369
|
+
}
|
|
370
|
+
throw receiptError;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function rollbackPromptCacheKeyConfig(
|
|
375
|
+
snapshot: PromptCacheKeyConfigReceiptSnapshot,
|
|
376
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
377
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
378
|
+
options: PromptCacheKeyRollbackOptions = {},
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(snapshot, configPath, receiptPath, options));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function isActionablePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt | undefined): receipt is PromptCacheKeyConfigReceipt {
|
|
384
|
+
return receipt !== undefined && receipt.status === undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function readPromptCacheKeyConfigReceiptSnapshot(
|
|
388
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
389
|
+
): Promise<PromptCacheKeyConfigReceiptSnapshot | undefined> {
|
|
390
|
+
try {
|
|
391
|
+
const info = await lstat(receiptPath);
|
|
392
|
+
if (info.isSymbolicLink() || !info.isFile()) return undefined;
|
|
393
|
+
const text = await readFile(receiptPath, "utf8");
|
|
394
|
+
const afterRead = await lstat(receiptPath);
|
|
395
|
+
if (afterRead.isSymbolicLink() || !afterRead.isFile() || !sameFileIdentity(info, afterRead)) return undefined;
|
|
396
|
+
const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(text));
|
|
397
|
+
if (!receipt) return undefined;
|
|
398
|
+
return { receipt, receiptPath, hash: hashText(text), identity: afterRead };
|
|
399
|
+
} catch {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
|
|
405
|
+
return (await readPromptCacheKeyConfigReceiptSnapshot(receiptPath))?.receipt;
|
|
406
|
+
}
|