cursor-opencode-provider 0.8.0 → 0.8.3
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/CHANGELOG.md +174 -0
- package/README.md +230 -44
- package/SECURITY.md +60 -12
- package/dist/activity.d.ts +73 -1
- package/dist/activity.js +231 -5
- package/dist/agent-url.js +37 -9
- package/dist/auth-renewal.d.ts +1 -1
- package/dist/auth-renewal.js +58 -26
- package/dist/billing.d.ts +105 -0
- package/dist/billing.js +212 -0
- package/dist/classic-tool-results.d.ts +1 -0
- package/dist/classic-tool-results.js +42 -0
- package/dist/context/build.d.ts +23 -4
- package/dist/context/build.js +65 -10
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/frozen.js +6 -2
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +45 -18
- package/dist/context/paths.js +72 -22
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +34 -0
- package/dist/context/skills.js +211 -0
- package/dist/debug.js +13 -2
- package/dist/early-tool-rows.d.ts +60 -0
- package/dist/early-tool-rows.js +96 -0
- package/dist/errors.d.ts +49 -7
- package/dist/errors.js +163 -8
- package/dist/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +122 -30
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +149 -30
- package/dist/language-model.js +3127 -673
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +12 -1
- package/dist/model-config.js +78 -30
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +233 -65
- package/dist/plugin.js +97 -23
- package/dist/pricing-data.d.ts +40 -3
- package/dist/pricing-data.js +40 -3
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +7 -4
- package/dist/process-shared.d.ts +14 -0
- package/dist/process-shared.js +18 -0
- package/dist/protocol/apply-patch.d.ts +25 -13
- package/dist/protocol/apply-patch.js +79 -30
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- package/dist/protocol/blob-store.d.ts +5 -1
- package/dist/protocol/blob-store.js +10 -6
- package/dist/protocol/client-version.js +6 -4
- package/dist/protocol/conversation-cache-header.d.ts +23 -0
- package/dist/protocol/conversation-cache-header.js +149 -0
- package/dist/protocol/conversation-persistence.d.ts +27 -1
- package/dist/protocol/conversation-persistence.js +628 -126
- package/dist/protocol/conversation-state.d.ts +8 -1
- package/dist/protocol/conversation-state.js +28 -7
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +164 -21
- package/dist/protocol/request.d.ts +40 -19
- package/dist/protocol/request.js +55 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +6 -0
- package/dist/protocol/tool-call-bridge.js +24 -0
- package/dist/protocol/tools.d.ts +90 -9
- package/dist/protocol/tools.js +543 -151
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +22 -4
- package/dist/replay-safety.js +86 -27
- package/dist/retry-budget.d.ts +21 -0
- package/dist/retry-budget.js +94 -0
- package/dist/runtime-files.d.ts +23 -0
- package/dist/runtime-files.js +581 -0
- package/dist/session.d.ts +129 -10
- package/dist/session.js +198 -35
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/shell-timeout.d.ts +54 -10
- package/dist/shell-timeout.js +362 -92
- package/dist/stream-write.d.ts +20 -0
- package/dist/stream-write.js +87 -0
- package/dist/terminal-runner.d.ts +15 -0
- package/dist/terminal-runner.js +528 -0
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +10 -1
- package/dist/transport/connect.js +124 -27
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +85 -34
- package/package.json +5 -3
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
- package/dist/protocol/thinking.d.ts +0 -15
- package/dist/protocol/thinking.js +0 -17
|
@@ -1,16 +1,81 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { chmod, link, lstat, mkdir, open, opendir, rename, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { constants as zlibConstants, gunzip, gzip } from "node:zlib";
|
|
6
|
+
import { promisify } from "node:util";
|
|
5
7
|
import protobuf from "protobufjs";
|
|
6
8
|
import { CONVERSATION_CACHE_DIR, CONVERSATION_CACHE_SCHEMA_VERSION, CONVERSATION_CACHE_TTL_MS, } from "../shared.js";
|
|
7
9
|
import { decodeMessageSparse, encodeMessage } from "./messages.js";
|
|
8
10
|
import { trace } from "../debug.js";
|
|
11
|
+
import { readConversationCacheHeader } from "./conversation-cache-header.js";
|
|
9
12
|
const stores = new Map();
|
|
10
13
|
const initializedRoots = new Map();
|
|
11
14
|
const startupDiscardedStatuses = new Map();
|
|
12
15
|
const MAX_CONVERSATION_CACHE_BYTES = 256 * 1024 * 1024;
|
|
13
16
|
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
17
|
+
const gunzipAsync = promisify(gunzip);
|
|
18
|
+
const gzipAsync = promisify(gzip);
|
|
19
|
+
const MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000;
|
|
20
|
+
const maintenanceRoots = new Map();
|
|
21
|
+
const fileOperations = new Map();
|
|
22
|
+
/** `.${snapshot}.${pid}.${uuid}.tmp`, as written by writeConversationFile. */
|
|
23
|
+
const WRITER_TEMP_FILE = /^\..+\.pb\.gz\.\d+\.[^.]+\.tmp$/;
|
|
24
|
+
let testHook;
|
|
25
|
+
function errorCode(error) {
|
|
26
|
+
return error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
27
|
+
}
|
|
28
|
+
function sameFile(left, right) {
|
|
29
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
30
|
+
}
|
|
31
|
+
async function withFileOperation(filePath, operation) {
|
|
32
|
+
const previous = fileOperations.get(filePath) ?? Promise.resolve();
|
|
33
|
+
const result = previous.catch(() => { }).then(operation);
|
|
34
|
+
const settled = result.then(() => { }, () => { });
|
|
35
|
+
fileOperations.set(filePath, settled);
|
|
36
|
+
void settled.then(() => {
|
|
37
|
+
if (fileOperations.get(filePath) === settled)
|
|
38
|
+
fileOperations.delete(filePath);
|
|
39
|
+
});
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function quarantineDirectory(filePath) {
|
|
43
|
+
return path.join(path.dirname(filePath), ".gc", path.basename(filePath));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Entries of a directory another collector may remove at any time. Bun reads
|
|
47
|
+
* a Dir lazily, so that ENOENT can surface from iteration instead of opendir;
|
|
48
|
+
* either way the directory is treated as empty.
|
|
49
|
+
*/
|
|
50
|
+
async function* removableDirectoryEntries(directory) {
|
|
51
|
+
let handle;
|
|
52
|
+
try {
|
|
53
|
+
handle = await opendir(directory);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (errorCode(error) === "ENOENT")
|
|
57
|
+
return;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await testHook?.("directory-opened", directory);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
await handle.close();
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
for await (const entry of handle)
|
|
69
|
+
yield entry;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (errorCode(error) !== "ENOENT")
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function openSnapshot(filePath) {
|
|
77
|
+
return open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
78
|
+
}
|
|
14
79
|
function recordStartupDiscard(cacheDir, fileName, status) {
|
|
15
80
|
const root = path.resolve(cacheDir);
|
|
16
81
|
let statuses = startupDiscardedStatuses.get(root);
|
|
@@ -39,6 +104,7 @@ function cloneConversation(value) {
|
|
|
39
104
|
requestContext: structuredClone(value.requestContext),
|
|
40
105
|
toolCatalog: structuredClone(value.toolCatalog),
|
|
41
106
|
postCompactionRebase: value.postCompactionRebase,
|
|
107
|
+
...(value.sentImageHashes ? { sentImageHashes: [...value.sentImageHashes] } : {}),
|
|
42
108
|
};
|
|
43
109
|
}
|
|
44
110
|
/**
|
|
@@ -48,7 +114,8 @@ function cloneConversation(value) {
|
|
|
48
114
|
* ConversationCache: schema_version=1, session_key=2, conversation_id=3,
|
|
49
115
|
* updated_at=4, checkpoint=5, blobs=6, request_context=7, tool_catalog=8,
|
|
50
116
|
* post_compaction_rebase=9, host_agent=10, system_prompt_hash=11,
|
|
51
|
-
* turn_provenance_json=12
|
|
117
|
+
* turn_provenance_json=12, host_note=13, early_steers_json=14,
|
|
118
|
+
* lost_request_json=15, sent_image_hashes=16 (repeated), sent_images_checkpoint_sha256=17.
|
|
52
119
|
* Blob: id=1, data=2. Tool: name=1, description=2,
|
|
53
120
|
* input_schema_json=3, source_name=4.
|
|
54
121
|
*
|
|
@@ -179,6 +246,17 @@ function encodeCacheFile(value) {
|
|
|
179
246
|
writer.uint32(fieldTag(11, 2)).string(value.systemPromptHash);
|
|
180
247
|
if (value.turnProvenance)
|
|
181
248
|
writer.uint32(fieldTag(12, 2)).string(value.turnProvenance);
|
|
249
|
+
if (value.hostNote)
|
|
250
|
+
writer.uint32(fieldTag(13, 2)).string(value.hostNote);
|
|
251
|
+
if (value.earlySteers)
|
|
252
|
+
writer.uint32(fieldTag(14, 2)).string(value.earlySteers);
|
|
253
|
+
if (value.lostRequest)
|
|
254
|
+
writer.uint32(fieldTag(15, 2)).string(value.lostRequest);
|
|
255
|
+
if (value.sentImagesCheckpoint && value.sentImageHashes?.length) {
|
|
256
|
+
for (const hash of value.sentImageHashes)
|
|
257
|
+
writer.uint32(fieldTag(16, 2)).string(hash);
|
|
258
|
+
writer.uint32(fieldTag(17, 2)).string(value.sentImagesCheckpoint);
|
|
259
|
+
}
|
|
182
260
|
return { protobufBytes: writer.finish(), requestContextBytes: requestContext.length };
|
|
183
261
|
}
|
|
184
262
|
function decodeProtobuf(data) {
|
|
@@ -195,27 +273,32 @@ function decodeProtobuf(data) {
|
|
|
195
273
|
let hostAgent;
|
|
196
274
|
let systemPromptHash;
|
|
197
275
|
let turnProvenance;
|
|
276
|
+
let hostNote;
|
|
277
|
+
let earlySteers;
|
|
278
|
+
let lostRequest;
|
|
279
|
+
const sentImageHashes = [];
|
|
280
|
+
let sentImagesCheckpoint;
|
|
198
281
|
while (reader.pos < reader.len) {
|
|
199
282
|
const tag = reader.uint32();
|
|
200
283
|
const wireType = tag & 7;
|
|
201
284
|
switch (tag >>> 3) {
|
|
202
285
|
case 1:
|
|
203
|
-
if (wireType !== 0)
|
|
286
|
+
if (wireType !== 0 || schemaVersion !== undefined)
|
|
204
287
|
throw new Error("invalid schema version");
|
|
205
288
|
schemaVersion = reader.uint32();
|
|
206
289
|
break;
|
|
207
290
|
case 2:
|
|
208
|
-
if (wireType !== 2)
|
|
291
|
+
if (wireType !== 2 || sessionKey !== undefined)
|
|
209
292
|
throw new Error("invalid session key");
|
|
210
293
|
sessionKey = reader.string();
|
|
211
294
|
break;
|
|
212
295
|
case 3:
|
|
213
|
-
if (wireType !== 2)
|
|
296
|
+
if (wireType !== 2 || conversationId !== undefined)
|
|
214
297
|
throw new Error("invalid conversation id");
|
|
215
298
|
conversationId = reader.string();
|
|
216
299
|
break;
|
|
217
300
|
case 4:
|
|
218
|
-
if (wireType !== 0)
|
|
301
|
+
if (wireType !== 0 || updatedAt !== undefined)
|
|
219
302
|
throw new Error("invalid update timestamp");
|
|
220
303
|
updatedAt = readUint64(reader);
|
|
221
304
|
break;
|
|
@@ -259,6 +342,31 @@ function decodeProtobuf(data) {
|
|
|
259
342
|
throw new Error("invalid turn provenance");
|
|
260
343
|
turnProvenance = reader.string();
|
|
261
344
|
break;
|
|
345
|
+
case 13:
|
|
346
|
+
if (wireType !== 2)
|
|
347
|
+
throw new Error("invalid host note");
|
|
348
|
+
hostNote = reader.string();
|
|
349
|
+
break;
|
|
350
|
+
case 14:
|
|
351
|
+
if (wireType !== 2)
|
|
352
|
+
throw new Error("invalid early steers");
|
|
353
|
+
earlySteers = reader.string();
|
|
354
|
+
break;
|
|
355
|
+
case 15:
|
|
356
|
+
if (wireType !== 2)
|
|
357
|
+
throw new Error("invalid lost request");
|
|
358
|
+
lostRequest = reader.string();
|
|
359
|
+
break;
|
|
360
|
+
case 16:
|
|
361
|
+
if (wireType !== 2)
|
|
362
|
+
throw new Error("invalid sent image hash");
|
|
363
|
+
sentImageHashes.push(reader.string());
|
|
364
|
+
break;
|
|
365
|
+
case 17:
|
|
366
|
+
if (wireType !== 2)
|
|
367
|
+
throw new Error("invalid sent images checkpoint");
|
|
368
|
+
sentImagesCheckpoint = reader.string();
|
|
369
|
+
break;
|
|
262
370
|
default:
|
|
263
371
|
reader.skipType(wireType);
|
|
264
372
|
}
|
|
@@ -280,90 +388,199 @@ function decodeProtobuf(data) {
|
|
|
280
388
|
...(hostAgent ? { hostAgent } : {}),
|
|
281
389
|
...(systemPromptHash ? { systemPromptHash } : {}),
|
|
282
390
|
...(turnProvenance ? { turnProvenance } : {}),
|
|
391
|
+
...(hostNote ? { hostNote } : {}),
|
|
392
|
+
...(earlySteers ? { earlySteers } : {}),
|
|
393
|
+
...(lostRequest ? { lostRequest } : {}),
|
|
394
|
+
...(sentImagesCheckpoint && sentImageHashes.length > 0 ? { sentImageHashes, sentImagesCheckpoint } : {}),
|
|
283
395
|
};
|
|
284
396
|
}
|
|
285
|
-
function decodeCacheFile(compressed, expectedSessionKey) {
|
|
286
|
-
try {
|
|
287
|
-
if (compressed.length > MAX_CONVERSATION_CACHE_BYTES)
|
|
288
|
-
return undefined;
|
|
289
|
-
const protobufBytes = gunzipSync(compressed, { maxOutputLength: MAX_CONVERSATION_CACHE_BYTES });
|
|
290
|
-
const conversation = decodeProtobuf(protobufBytes);
|
|
291
|
-
if (expectedSessionKey && conversation.sessionKey !== expectedSessionKey)
|
|
292
|
-
return undefined;
|
|
293
|
-
return conversation;
|
|
294
|
-
}
|
|
295
|
-
catch {
|
|
296
|
-
return undefined;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
397
|
function isFresh(value, now) {
|
|
300
398
|
return now - value.updatedAt <= CONVERSATION_CACHE_TTL_MS;
|
|
301
399
|
}
|
|
302
|
-
|
|
400
|
+
/** Recover only this session's moved generations; never scan the conversation root. */
|
|
401
|
+
async function openConversationFile(filePath) {
|
|
303
402
|
try {
|
|
304
|
-
return
|
|
403
|
+
return { handle: await openSnapshot(filePath), sourcePath: filePath };
|
|
305
404
|
}
|
|
306
405
|
catch (error) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
: undefined;
|
|
310
|
-
if (code !== "ENOENT")
|
|
311
|
-
return undefined;
|
|
312
|
-
return undefined;
|
|
406
|
+
if (errorCode(error) !== "ENOENT")
|
|
407
|
+
throw error;
|
|
313
408
|
}
|
|
314
|
-
|
|
315
|
-
|
|
409
|
+
let selected;
|
|
410
|
+
let newest = -Infinity;
|
|
316
411
|
try {
|
|
317
|
-
const
|
|
318
|
-
|
|
412
|
+
for await (const entry of removableDirectoryEntries(quarantineDirectory(filePath))) {
|
|
413
|
+
if (!entry.isFile() || !entry.name.endsWith(".snapshot"))
|
|
414
|
+
continue;
|
|
415
|
+
const sourcePath = path.join(quarantineDirectory(filePath), entry.name);
|
|
416
|
+
let handle;
|
|
417
|
+
try {
|
|
418
|
+
handle = await openSnapshot(sourcePath);
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
if (errorCode(error) === "ENOENT")
|
|
422
|
+
continue;
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const header = await readConversationCacheHeader(handle);
|
|
427
|
+
const updatedAt = header.status === "metadata" ? header.value.updatedAt : -Infinity;
|
|
428
|
+
if (!selected || updatedAt > newest) {
|
|
429
|
+
await selected?.handle.close();
|
|
430
|
+
selected = { handle, sourcePath };
|
|
431
|
+
newest = updatedAt;
|
|
432
|
+
}
|
|
433
|
+
else
|
|
434
|
+
await handle.close();
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
await handle.close();
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// A collector may have restored its candidate, or a writer published a new
|
|
442
|
+
// generation while we inspected quarantine. The live name wins either way.
|
|
443
|
+
try {
|
|
444
|
+
const handle = await openSnapshot(filePath);
|
|
445
|
+
await selected?.handle.close();
|
|
446
|
+
return { handle, sourcePath: filePath };
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (errorCode(error) !== "ENOENT")
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
return selected;
|
|
319
453
|
}
|
|
320
454
|
catch (error) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
: undefined;
|
|
324
|
-
return { status: code === "ENOENT" ? "missing" : "invalid" };
|
|
455
|
+
await selected?.handle.close();
|
|
456
|
+
throw error;
|
|
325
457
|
}
|
|
326
458
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
async function discardCacheCandidate(filePath, now) {
|
|
334
|
-
const quarantinePath = `${filePath}.${process.pid}.${randomUUID()}.stale`;
|
|
459
|
+
async function readConversationFileWithStatus(filePath, expectedSessionKey, now) {
|
|
460
|
+
const opened = await openConversationFile(filePath);
|
|
461
|
+
if (!opened)
|
|
462
|
+
return { status: "missing" };
|
|
463
|
+
const { handle, sourcePath } = opened;
|
|
464
|
+
let keepOpen = false;
|
|
335
465
|
try {
|
|
336
|
-
await
|
|
466
|
+
const stat = await handle.stat({ bigint: true });
|
|
467
|
+
const identity = { dev: stat.dev, ino: stat.ino };
|
|
468
|
+
const discarded = (status) => {
|
|
469
|
+
keepOpen = true;
|
|
470
|
+
return { status, identity, sourcePath, pin: handle };
|
|
471
|
+
};
|
|
472
|
+
if (!stat.isFile())
|
|
473
|
+
throw new Error("conversation snapshot is not a regular file");
|
|
474
|
+
if (stat.size > MAX_CONVERSATION_CACHE_BYTES)
|
|
475
|
+
return discarded("invalid");
|
|
476
|
+
const header = await readConversationCacheHeader(handle);
|
|
477
|
+
if (header.status === "invalid")
|
|
478
|
+
return discarded("invalid");
|
|
479
|
+
if (header.status === "metadata") {
|
|
480
|
+
if (header.value.sessionKey !== expectedSessionKey)
|
|
481
|
+
return discarded("invalid");
|
|
482
|
+
if (!isFresh(header.value, now))
|
|
483
|
+
return discarded("expired");
|
|
484
|
+
if (header.value.schemaVersion !== CONVERSATION_CACHE_SCHEMA_VERSION)
|
|
485
|
+
return discarded("invalid");
|
|
486
|
+
}
|
|
487
|
+
const compressed = await handle.readFile();
|
|
488
|
+
if (compressed.length > MAX_CONVERSATION_CACHE_BYTES)
|
|
489
|
+
return discarded("invalid");
|
|
490
|
+
await testHook?.("full-decode", filePath);
|
|
491
|
+
let value;
|
|
492
|
+
try {
|
|
493
|
+
const bytes = await gunzipAsync(compressed, { maxOutputLength: MAX_CONVERSATION_CACHE_BYTES });
|
|
494
|
+
value = decodeProtobuf(bytes);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return discarded("invalid");
|
|
498
|
+
}
|
|
499
|
+
if (value.sessionKey !== expectedSessionKey)
|
|
500
|
+
return discarded("invalid");
|
|
501
|
+
return isFresh(value, now)
|
|
502
|
+
? { status: "restored", value, identity, sourcePath }
|
|
503
|
+
: discarded("expired");
|
|
337
504
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
: undefined;
|
|
342
|
-
if (code === "ENOENT")
|
|
343
|
-
return undefined;
|
|
344
|
-
throw error;
|
|
505
|
+
finally {
|
|
506
|
+
if (!keepOpen)
|
|
507
|
+
await handle.close();
|
|
345
508
|
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Quarantine before deletion, then compare FILE IDENTITY rather than age. An
|
|
512
|
+
* atomic replacement has a different inode and must be restored without clobbering
|
|
513
|
+
* the live name. Readers can open this session's quarantine during that interval.
|
|
514
|
+
*/
|
|
515
|
+
async function discardCacheCandidate(filePath, identity, signal) {
|
|
516
|
+
const directory = quarantineDirectory(filePath);
|
|
517
|
+
const quarantinePath = path.join(directory, `${randomUUID()}.snapshot`);
|
|
518
|
+
for (let attempt = 0;; attempt++) {
|
|
519
|
+
await ensureCacheDirectory(directory);
|
|
520
|
+
if (attempt === 0)
|
|
521
|
+
await testHook?.("before-quarantine", filePath);
|
|
522
|
+
signal?.throwIfAborted();
|
|
523
|
+
try {
|
|
524
|
+
await rename(filePath, quarantinePath);
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
if (errorCode(error) !== "ENOENT")
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
// ENOENT names either side: the candidate is gone, or another collector
|
|
532
|
+
// removed this emptied quarantine directory after we created it.
|
|
533
|
+
try {
|
|
534
|
+
await lstat(filePath);
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
if (errorCode(error) === "ENOENT")
|
|
538
|
+
return "missing";
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
if (attempt === 2)
|
|
542
|
+
return "preserved";
|
|
543
|
+
}
|
|
544
|
+
let removable = false;
|
|
346
545
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
546
|
+
await testHook?.("after-quarantine", filePath);
|
|
547
|
+
let moved;
|
|
548
|
+
try {
|
|
549
|
+
moved = await openSnapshot(quarantinePath);
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
// Another collector can finish/recover this immutable candidate first.
|
|
553
|
+
if (errorCode(error) === "ENOENT")
|
|
554
|
+
return "missing";
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const stat = await moved.stat({ bigint: true });
|
|
559
|
+
removable = !signal?.aborted && sameFile(identity, stat);
|
|
560
|
+
}
|
|
561
|
+
finally {
|
|
562
|
+
await moved.close();
|
|
361
563
|
}
|
|
362
564
|
}
|
|
363
565
|
finally {
|
|
364
|
-
|
|
566
|
+
if (!removable) {
|
|
567
|
+
// If restoration fails, retain the discoverable quarantine for the next
|
|
568
|
+
// reader/pass. Never unlink the only surviving copy in a finally block.
|
|
569
|
+
await link(quarantinePath, filePath).catch((error) => {
|
|
570
|
+
if (errorCode(error) !== "EEXIST" && errorCode(error) !== "ENOENT")
|
|
571
|
+
throw error;
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
await unlink(quarantinePath).catch((error) => {
|
|
575
|
+
if (errorCode(error) !== "ENOENT")
|
|
576
|
+
throw error;
|
|
577
|
+
});
|
|
578
|
+
await rmdir(directory).catch((error) => {
|
|
579
|
+
if (errorCode(error) !== "ENOENT" && errorCode(error) !== "ENOTEMPTY")
|
|
580
|
+
throw error;
|
|
581
|
+
});
|
|
365
582
|
}
|
|
366
|
-
return
|
|
583
|
+
return removable ? "removed" : "preserved";
|
|
367
584
|
}
|
|
368
585
|
async function writeConversationFile(cacheDir, value) {
|
|
369
586
|
const directory = conversationCacheDirectoryPath(cacheDir);
|
|
@@ -371,7 +588,7 @@ async function writeConversationFile(cacheDir, value) {
|
|
|
371
588
|
const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
372
589
|
const encoded = encodeCacheFile(value);
|
|
373
590
|
const { protobufBytes } = encoded;
|
|
374
|
-
const compressed =
|
|
591
|
+
const compressed = await gzipAsync(protobufBytes, { level: zlibConstants.Z_BEST_COMPRESSION });
|
|
375
592
|
await ensureCacheDirectory(directory);
|
|
376
593
|
try {
|
|
377
594
|
await writeFile(tempPath, compressed, { mode: 0o600 });
|
|
@@ -388,64 +605,265 @@ async function writeConversationFile(cacheDir, value) {
|
|
|
388
605
|
}
|
|
389
606
|
}
|
|
390
607
|
async function ensureCacheDirectory(directory) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
608
|
+
for (let attempt = 0;; attempt++) {
|
|
609
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
610
|
+
if (process.platform === "win32")
|
|
611
|
+
return;
|
|
612
|
+
try {
|
|
613
|
+
await chmod(directory, 0o700);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
// A concurrent collector can rmdir an emptied quarantine directory
|
|
618
|
+
// between mkdir and chmod; recreate it instead of failing the pass.
|
|
619
|
+
if (errorCode(error) !== "ENOENT" || attempt === 2)
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
394
623
|
}
|
|
395
624
|
function queueWrite(store, value) {
|
|
396
625
|
const snapshot = cloneConversation(value);
|
|
397
|
-
const
|
|
626
|
+
const filePath = conversationCacheFilePath(store.cacheDir, store.sessionKey);
|
|
627
|
+
const write = store.writeChain.catch(() => { }).then(() => withFileOperation(filePath, () => writeConversationFile(store.cacheDir, snapshot)));
|
|
398
628
|
store.writeChain = write;
|
|
399
629
|
return write;
|
|
400
630
|
}
|
|
401
|
-
function queueDelete(store) {
|
|
631
|
+
function queueDelete(store, expectedConversationId) {
|
|
402
632
|
const write = store.writeChain.catch(() => { }).then(async () => {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
633
|
+
const filePath = conversationCacheFilePath(store.cacheDir, store.sessionKey);
|
|
634
|
+
await withFileOperation(filePath, async () => {
|
|
635
|
+
const opened = await openConversationFile(filePath);
|
|
636
|
+
if (!opened)
|
|
637
|
+
return;
|
|
638
|
+
try {
|
|
639
|
+
const stat = await opened.handle.stat({ bigint: true });
|
|
640
|
+
const header = await readConversationCacheHeader(opened.handle);
|
|
641
|
+
if (header.status !== "metadata" || header.value.sessionKey !== store.sessionKey ||
|
|
642
|
+
header.value.conversationId !== expectedConversationId)
|
|
643
|
+
return;
|
|
644
|
+
if (opened.sourcePath === filePath)
|
|
645
|
+
await discardCacheCandidate(filePath, stat);
|
|
646
|
+
else
|
|
647
|
+
await unlink(opened.sourcePath).catch((error) => {
|
|
648
|
+
if (errorCode(error) !== "ENOENT")
|
|
649
|
+
throw error;
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
await opened.handle.close();
|
|
654
|
+
}
|
|
409
655
|
});
|
|
410
656
|
});
|
|
411
657
|
store.writeChain = write;
|
|
412
658
|
return write;
|
|
413
659
|
}
|
|
414
|
-
async function
|
|
660
|
+
async function recoverQuarantine(filePath, now, signal) {
|
|
661
|
+
for await (const entry of removableDirectoryEntries(quarantineDirectory(filePath))) {
|
|
662
|
+
signal.throwIfAborted();
|
|
663
|
+
if (!entry.isFile() || !entry.name.endsWith(".snapshot"))
|
|
664
|
+
continue;
|
|
665
|
+
const candidate = path.join(quarantineDirectory(filePath), entry.name);
|
|
666
|
+
let handle;
|
|
667
|
+
try {
|
|
668
|
+
handle = await openSnapshot(candidate);
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
if (errorCode(error) === "ENOENT")
|
|
672
|
+
continue;
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
let status;
|
|
676
|
+
try {
|
|
677
|
+
const header = await readConversationCacheHeader(handle, signal);
|
|
678
|
+
if (header.status === "invalid")
|
|
679
|
+
status = "invalid";
|
|
680
|
+
else if (header.status === "metadata" && !isFresh(header.value, now))
|
|
681
|
+
status = "expired";
|
|
682
|
+
}
|
|
683
|
+
finally {
|
|
684
|
+
await handle.close();
|
|
685
|
+
}
|
|
686
|
+
signal.throwIfAborted();
|
|
687
|
+
if (!status) {
|
|
688
|
+
await link(candidate, filePath).catch((error) => {
|
|
689
|
+
if (errorCode(error) !== "EEXIST" && errorCode(error) !== "ENOENT")
|
|
690
|
+
throw error;
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
await unlink(candidate).catch((error) => {
|
|
694
|
+
if (errorCode(error) !== "ENOENT")
|
|
695
|
+
throw error;
|
|
696
|
+
});
|
|
697
|
+
if (status)
|
|
698
|
+
recordStartupDiscard(path.dirname(path.dirname(filePath)), path.basename(filePath), status);
|
|
699
|
+
}
|
|
700
|
+
await rmdir(quarantineDirectory(filePath)).catch((error) => {
|
|
701
|
+
if (errorCode(error) !== "ENOENT" && errorCode(error) !== "ENOTEMPTY")
|
|
702
|
+
throw error;
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
async function pruneConversationDirectory(cacheDir, now, signal) {
|
|
415
706
|
const directory = conversationCacheDirectoryPath(cacheDir);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
await
|
|
707
|
+
// Recover crash leftovers before inspecting canonical names. Only per-session
|
|
708
|
+
// directories are read, and readers use exactly the same recovery location.
|
|
709
|
+
for await (const entry of removableDirectoryEntries(path.join(directory, ".gc"))) {
|
|
710
|
+
signal.throwIfAborted();
|
|
711
|
+
if (!entry.isDirectory() || !entry.name.endsWith(".pb.gz"))
|
|
712
|
+
continue;
|
|
713
|
+
const filePath = path.join(directory, entry.name);
|
|
714
|
+
await maintainEntry(entry.name, signal, () => withFileOperation(filePath, () => recoverQuarantine(filePath, now, signal)));
|
|
715
|
+
}
|
|
716
|
+
const entries = await opendir(directory);
|
|
717
|
+
for await (const entry of entries) {
|
|
718
|
+
signal.throwIfAborted();
|
|
419
719
|
if (!entry.isFile())
|
|
420
|
-
|
|
720
|
+
continue;
|
|
421
721
|
const filePath = path.join(directory, entry.name);
|
|
422
722
|
if (entry.name.endsWith(".json")) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
723
|
+
await maintainEntry(entry.name, signal, () => unlink(filePath).catch((error) => {
|
|
724
|
+
if (errorCode(error) !== "ENOENT")
|
|
725
|
+
throw error;
|
|
726
|
+
}));
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (WRITER_TEMP_FILE.test(entry.name)) {
|
|
730
|
+
await maintainEntry(entry.name, signal, () => collectWriterTemp(filePath, now, signal));
|
|
731
|
+
continue;
|
|
426
732
|
}
|
|
427
733
|
if (!entry.name.endsWith(".pb.gz"))
|
|
734
|
+
continue;
|
|
735
|
+
// In-process loads/writes/deletes share this per-file queue, so a loaded
|
|
736
|
+
// session's snapshot still expires by its persisted age without restart.
|
|
737
|
+
await maintainEntry(entry.name, signal, () => withFileOperation(filePath, async () => {
|
|
738
|
+
signal.throwIfAborted();
|
|
739
|
+
let handle;
|
|
740
|
+
try {
|
|
741
|
+
handle = await openSnapshot(filePath);
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
if (errorCode(error) === "ENOENT")
|
|
745
|
+
return;
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
const stat = await handle.stat({ bigint: true });
|
|
750
|
+
if (!stat.isFile())
|
|
751
|
+
return;
|
|
752
|
+
const identity = { dev: stat.dev, ino: stat.ino };
|
|
753
|
+
let status;
|
|
754
|
+
const header = await readConversationCacheHeader(handle, signal);
|
|
755
|
+
if (header.status === "invalid")
|
|
756
|
+
status = "invalid";
|
|
757
|
+
else if (header.status === "metadata" && !isFresh(header.value, now))
|
|
758
|
+
status = "expired";
|
|
759
|
+
if (!status)
|
|
760
|
+
return;
|
|
761
|
+
signal.throwIfAborted();
|
|
762
|
+
if (await discardCacheCandidate(filePath, identity, signal) === "removed") {
|
|
763
|
+
recordStartupDiscard(cacheDir, entry.name, status);
|
|
764
|
+
trace(`conversation persistence: maintenance discarded ${status} snapshot file=${entry.name}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
finally {
|
|
768
|
+
await handle.close();
|
|
769
|
+
}
|
|
770
|
+
}));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/** One entry's I/O failure must not starve every later entry of collection. */
|
|
774
|
+
async function maintainEntry(fileName, signal, operation) {
|
|
775
|
+
try {
|
|
776
|
+
await operation();
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
if (signal.aborted)
|
|
780
|
+
throw error;
|
|
781
|
+
trace(`conversation persistence: maintenance skipped file=${fileName}: ${String(error)}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* A writer killed between writeFile and rename leaves its temp file behind.
|
|
786
|
+
* An in-flight writer's temp always carries a fresh timestamp, so only a
|
|
787
|
+
* readable expired prefix proves abandonment; unreadable temps are kept.
|
|
788
|
+
*/
|
|
789
|
+
async function collectWriterTemp(filePath, now, signal) {
|
|
790
|
+
let handle;
|
|
791
|
+
try {
|
|
792
|
+
handle = await openSnapshot(filePath);
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
if (errorCode(error) === "ENOENT")
|
|
428
796
|
return;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
let expired = false;
|
|
800
|
+
try {
|
|
801
|
+
if (!(await handle.stat()).isFile())
|
|
802
|
+
return;
|
|
803
|
+
const header = await readConversationCacheHeader(handle, signal);
|
|
804
|
+
expired = header.status === "metadata" && !isFresh(header.value, now);
|
|
805
|
+
}
|
|
806
|
+
finally {
|
|
807
|
+
await handle.close();
|
|
808
|
+
}
|
|
809
|
+
if (!expired)
|
|
810
|
+
return;
|
|
811
|
+
signal.throwIfAborted();
|
|
812
|
+
await unlink(filePath).catch((error) => {
|
|
813
|
+
if (errorCode(error) !== "ENOENT")
|
|
814
|
+
throw error;
|
|
815
|
+
});
|
|
816
|
+
trace(`conversation persistence: maintenance discarded abandoned temp file=${path.basename(filePath)}`);
|
|
442
817
|
}
|
|
443
|
-
|
|
818
|
+
function scheduleMaintenance(cacheDir, now, force = false) {
|
|
819
|
+
let state = maintenanceRoots.get(cacheDir);
|
|
820
|
+
if (!state) {
|
|
821
|
+
state = { controller: new AbortController() };
|
|
822
|
+
maintenanceRoots.set(cacheDir, state);
|
|
823
|
+
}
|
|
824
|
+
if (state.task || state.controller.signal.aborted)
|
|
825
|
+
return;
|
|
826
|
+
if (!force && state.lastStarted !== undefined && now - state.lastStarted < MAINTENANCE_INTERVAL_MS)
|
|
827
|
+
return;
|
|
828
|
+
const current = state;
|
|
829
|
+
const signal = current.controller.signal;
|
|
830
|
+
const task = (async () => {
|
|
831
|
+
await new Promise((resolve) => {
|
|
832
|
+
const abort = () => {
|
|
833
|
+
clearImmediate(current.scheduled);
|
|
834
|
+
resolve();
|
|
835
|
+
};
|
|
836
|
+
current.scheduled = setImmediate(() => {
|
|
837
|
+
signal.removeEventListener("abort", abort);
|
|
838
|
+
current.scheduled = undefined;
|
|
839
|
+
resolve();
|
|
840
|
+
});
|
|
841
|
+
current.scheduled.unref();
|
|
842
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
843
|
+
});
|
|
844
|
+
signal.throwIfAborted();
|
|
845
|
+
current.lastStarted = now;
|
|
846
|
+
await testHook?.("maintenance-start", cacheDir);
|
|
847
|
+
signal.throwIfAborted();
|
|
848
|
+
await pruneConversationDirectory(cacheDir, now, signal);
|
|
849
|
+
})();
|
|
850
|
+
current.task = task;
|
|
851
|
+
const done = () => {
|
|
852
|
+
if (current.task === task)
|
|
853
|
+
current.task = undefined;
|
|
854
|
+
};
|
|
855
|
+
void task.then(done, (error) => {
|
|
856
|
+
done();
|
|
857
|
+
if (!signal.aborted)
|
|
858
|
+
trace(`conversation persistence: maintenance failed: ${String(error)}`);
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
/** Readiness includes directory permissions only; maintenance never gates hydration. */
|
|
444
862
|
export async function initializeConversationPersistence(cacheDir, now = Date.now()) {
|
|
445
863
|
const key = path.resolve(cacheDir);
|
|
446
864
|
let pending = initializedRoots.get(key);
|
|
447
865
|
if (!pending) {
|
|
448
|
-
pending =
|
|
866
|
+
pending = ensureCacheDirectory(conversationCacheDirectoryPath(key));
|
|
449
867
|
initializedRoots.set(key, pending);
|
|
450
868
|
pending.catch(() => {
|
|
451
869
|
if (initializedRoots.get(key) === pending)
|
|
@@ -453,29 +871,83 @@ export async function initializeConversationPersistence(cacheDir, now = Date.now
|
|
|
453
871
|
});
|
|
454
872
|
}
|
|
455
873
|
await pending;
|
|
874
|
+
if (initializedRoots.get(key) === pending)
|
|
875
|
+
scheduleMaintenance(key, now);
|
|
456
876
|
}
|
|
457
877
|
async function loadStore(cacheDir, sessionKey, now) {
|
|
458
878
|
await initializeConversationPersistence(cacheDir, now);
|
|
459
879
|
const filePath = conversationCacheFilePath(cacheDir, sessionKey);
|
|
460
|
-
const loaded = await
|
|
461
|
-
|
|
462
|
-
|
|
880
|
+
const loaded = await withFileOperation(filePath, async () => {
|
|
881
|
+
const candidate = await readConversationFileWithStatus(filePath, sessionKey, now);
|
|
882
|
+
try {
|
|
883
|
+
if ((candidate.status === "invalid" || candidate.status === "expired") && candidate.identity) {
|
|
884
|
+
if (candidate.sourcePath === filePath)
|
|
885
|
+
await discardCacheCandidate(filePath, candidate.identity);
|
|
886
|
+
else if (candidate.sourcePath)
|
|
887
|
+
await unlink(candidate.sourcePath).catch((error) => {
|
|
888
|
+
if (errorCode(error) !== "ENOENT")
|
|
889
|
+
throw error;
|
|
890
|
+
});
|
|
891
|
+
// A concurrent writer/recovery can replace the candidate during removal.
|
|
892
|
+
const replacement = await readConversationFileWithStatus(filePath, sessionKey, now);
|
|
893
|
+
await replacement.pin?.close();
|
|
894
|
+
if (replacement.status !== "missing")
|
|
895
|
+
return replacement;
|
|
896
|
+
}
|
|
897
|
+
return candidate;
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
await candidate.pin?.close();
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
const value = loaded.value && loaded.value.sessionKey === sessionKey && isFresh(loaded.value, now)
|
|
904
|
+
? loaded.value
|
|
905
|
+
: undefined;
|
|
906
|
+
const loadStatus = loaded.status === "missing"
|
|
463
907
|
? startupDiscardedStatuses.get(path.resolve(cacheDir))?.get(path.basename(filePath)) ?? "missing"
|
|
464
908
|
: loaded.status;
|
|
465
|
-
if (value && !isFresh(value, now)) {
|
|
466
|
-
loadStatus = "expired";
|
|
467
|
-
value = await discardCacheCandidate(filePath, now);
|
|
468
|
-
if (value && isFresh(value, now))
|
|
469
|
-
loadStatus = "restored";
|
|
470
|
-
}
|
|
471
909
|
return {
|
|
472
910
|
cacheDir,
|
|
473
911
|
sessionKey,
|
|
474
|
-
value
|
|
912
|
+
...(value ? { head: conversationHead(value), value } : {}),
|
|
475
913
|
loadStatus,
|
|
476
914
|
writeChain: Promise.resolve(),
|
|
477
915
|
};
|
|
478
916
|
}
|
|
917
|
+
function conversationHead(value) {
|
|
918
|
+
return {
|
|
919
|
+
conversationId: value.conversationId,
|
|
920
|
+
updatedAt: value.updatedAt,
|
|
921
|
+
...(value.turnProvenance ? { turnProvenance: value.turnProvenance } : {}),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Hand the loaded snapshot to its first reader, then read the durable copy.
|
|
926
|
+
* Hydration needs it only while the session has no live binding, so keeping
|
|
927
|
+
* a process-lifetime clone of every checkpoint and blob graph buys nothing.
|
|
928
|
+
*/
|
|
929
|
+
async function takeConversation(store, now) {
|
|
930
|
+
const head = store.head;
|
|
931
|
+
if (!head)
|
|
932
|
+
return { status: store.loadStatus };
|
|
933
|
+
const held = store.value;
|
|
934
|
+
store.value = undefined;
|
|
935
|
+
if (held && isFresh(held, now))
|
|
936
|
+
return { status: "restored", value: held };
|
|
937
|
+
await store.writeChain.catch(() => { });
|
|
938
|
+
const filePath = conversationCacheFilePath(store.cacheDir, store.sessionKey);
|
|
939
|
+
const loaded = await withFileOperation(filePath, async () => {
|
|
940
|
+
const candidate = await readConversationFileWithStatus(filePath, store.sessionKey, now);
|
|
941
|
+
await candidate.pin?.close();
|
|
942
|
+
return candidate;
|
|
943
|
+
});
|
|
944
|
+
// A persist/delete that ran meanwhile owns the head; only report this read.
|
|
945
|
+
if (store.head === head) {
|
|
946
|
+
store.head = loaded.value ? conversationHead(loaded.value) : undefined;
|
|
947
|
+
store.loadStatus = loaded.status;
|
|
948
|
+
}
|
|
949
|
+
return loaded.value ? { status: "restored", value: loaded.value } : { status: loaded.status };
|
|
950
|
+
}
|
|
479
951
|
async function getStore(cacheDir, sessionKey, now = Date.now()) {
|
|
480
952
|
const root = path.resolve(cacheDir);
|
|
481
953
|
const key = `${root}\0${sessionKey}`;
|
|
@@ -491,42 +963,72 @@ async function getStore(cacheDir, sessionKey, now = Date.now()) {
|
|
|
491
963
|
return pending;
|
|
492
964
|
}
|
|
493
965
|
export async function getPersistedConversation(cacheDir, sessionKey, now = Date.now()) {
|
|
494
|
-
|
|
495
|
-
return store.value ? cloneConversation(store.value) : undefined;
|
|
966
|
+
return (await loadPersistedConversation(cacheDir, sessionKey, now)).value;
|
|
496
967
|
}
|
|
497
968
|
/** Read a snapshot together with the reason restart hydration did not restore it. */
|
|
498
969
|
export async function loadPersistedConversation(cacheDir, sessionKey, now = Date.now()) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
970
|
+
return takeConversation(await getStore(cacheDir, sessionKey, now), now);
|
|
971
|
+
}
|
|
972
|
+
/** Turn provenance of the current snapshot, without decoding its payload. */
|
|
973
|
+
export async function getPersistedTurnProvenance(cacheDir, sessionKey, now = Date.now()) {
|
|
974
|
+
const head = (await getStore(cacheDir, sessionKey, now)).head;
|
|
975
|
+
if (!head?.turnProvenance || !isFresh(head, now))
|
|
976
|
+
return undefined;
|
|
977
|
+
return { conversationId: head.conversationId, turnProvenance: head.turnProvenance };
|
|
503
978
|
}
|
|
504
979
|
/** Replace one session's durable snapshot and refresh its 24-hour lease. */
|
|
505
980
|
export async function persistConversation(cacheDir, value, now = Date.now()) {
|
|
506
981
|
const store = await getStore(cacheDir, value.sessionKey, now);
|
|
507
|
-
|
|
982
|
+
// queueWrite takes its own copy; the store keeps only the head.
|
|
983
|
+
const snapshot = {
|
|
508
984
|
...value,
|
|
509
985
|
updatedAt: now,
|
|
510
|
-
toolCatalog:
|
|
986
|
+
toolCatalog: value.toolCatalog ?? [],
|
|
511
987
|
postCompactionRebase: value.postCompactionRebase === true,
|
|
512
|
-
}
|
|
513
|
-
store.
|
|
988
|
+
};
|
|
989
|
+
store.head = conversationHead(snapshot);
|
|
990
|
+
store.value = undefined;
|
|
514
991
|
store.loadStatus = "restored";
|
|
515
992
|
startupDiscardedStatuses.get(path.resolve(cacheDir))?.delete(path.basename(conversationCacheFilePath(cacheDir, value.sessionKey)));
|
|
993
|
+
await testHook?.("before-write", conversationCacheFilePath(cacheDir, value.sessionKey));
|
|
516
994
|
await queueWrite(store, snapshot);
|
|
995
|
+
scheduleMaintenance(path.resolve(cacheDir), now);
|
|
517
996
|
}
|
|
518
997
|
/** Delete only the expected binding so an overlapping newer turn cannot be removed. */
|
|
519
998
|
export async function deletePersistedConversation(cacheDir, sessionKey, expectedConversationId) {
|
|
520
999
|
const store = await getStore(cacheDir, sessionKey);
|
|
521
|
-
const current = store.
|
|
1000
|
+
const current = store.head;
|
|
522
1001
|
if (!current || (expectedConversationId && current.conversationId !== expectedConversationId))
|
|
523
1002
|
return;
|
|
1003
|
+
store.head = undefined;
|
|
524
1004
|
store.value = undefined;
|
|
525
1005
|
store.loadStatus = "missing";
|
|
526
|
-
await queueDelete(store);
|
|
1006
|
+
await queueDelete(store, current.conversationId);
|
|
1007
|
+
}
|
|
1008
|
+
/** Full snapshots still held in memory; the store should keep heads only. */
|
|
1009
|
+
export async function retainedConversationSnapshotsForTests() {
|
|
1010
|
+
const settled = await Promise.allSettled(stores.values());
|
|
1011
|
+
return settled.filter((result) => result.status === "fulfilled" && result.value.value).length;
|
|
527
1012
|
}
|
|
528
1013
|
export function resetConversationPersistenceForTests() {
|
|
1014
|
+
for (const state of maintenanceRoots.values())
|
|
1015
|
+
state.controller.abort();
|
|
1016
|
+
maintenanceRoots.clear();
|
|
1017
|
+
fileOperations.clear();
|
|
1018
|
+
testHook = undefined;
|
|
529
1019
|
stores.clear();
|
|
530
1020
|
initializedRoots.clear();
|
|
531
1021
|
startupDiscardedStatuses.clear();
|
|
532
1022
|
}
|
|
1023
|
+
/** Deterministic maintenance barriers/race injection; not a request-path API. */
|
|
1024
|
+
export function setConversationPersistenceHookForTests(hook) {
|
|
1025
|
+
testHook = hook;
|
|
1026
|
+
}
|
|
1027
|
+
export async function waitForConversationMaintenanceForTests(cacheDir, now = Date.now()) {
|
|
1028
|
+
const root = path.resolve(cacheDir);
|
|
1029
|
+
await initializeConversationPersistence(root, now);
|
|
1030
|
+
scheduleMaintenance(root, now, true);
|
|
1031
|
+
const state = maintenanceRoots.get(root);
|
|
1032
|
+
state.scheduled?.ref();
|
|
1033
|
+
await state.task;
|
|
1034
|
+
}
|