@giovannijecha/jecode 0.8.3 → 0.8.5
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 +11 -8
- package/assets/wordmark-steel.svg +3 -0
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch.js +49 -4
- package/dist/bounded-file.js +212 -0
- package/dist/commands.js +2 -0
- package/dist/config.js +8 -4
- package/dist/context/automatic.js +35 -0
- package/dist/context/compactor.js +32 -2
- package/dist/context/manual.js +20 -3
- package/dist/context/request-projection.js +130 -0
- package/dist/controller-request.js +33 -11
- package/dist/credential-commands.js +22 -6
- package/dist/credentials.js +56 -18
- package/dist/directory-anchor.js +91 -0
- package/dist/file-identity.js +12 -0
- package/dist/model-command.js +5 -4
- package/dist/openai-account-command.js +13 -2
- package/dist/process-lease.js +329 -0
- package/dist/provider-commands.js +53 -10
- package/dist/provider-errors.js +94 -3
- package/dist/provider-label.js +13 -0
- package/dist/providers/anthropic-stream.js +4 -1
- package/dist/providers/anthropic-wire.js +8 -3
- package/dist/providers/anthropic.js +39 -19
- package/dist/providers/catalog.js +4 -4
- package/dist/providers/failure.js +181 -0
- package/dist/providers/http.js +86 -57
- package/dist/providers/ollama-stream.js +5 -1
- package/dist/providers/ollama.js +31 -19
- package/dist/providers/openai-codex.js +67 -41
- package/dist/providers/openai-stream.js +69 -9
- package/dist/providers/openai.js +51 -24
- package/dist/providers/sse.js +89 -17
- package/dist/request-identity.js +32 -0
- package/dist/sessions/catalog.js +199 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/store.js +451 -183
- package/dist/settings.js +62 -10
- package/dist/stable-directory.js +148 -0
- package/dist/store-lock.js +68 -84
- package/dist/tools/args.js +2 -2
- package/dist/tools/fs.js +124 -102
- package/dist/tools/search.js +81 -107
- package/dist/tools/text-boundary.js +7 -33
- package/dist/tui/app-workflows.js +32 -4
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/feedback.js +4 -0
- package/dist/tui/session-view.js +7 -2
- package/dist/tui/workspace.js +21 -7
- package/dist/user-store.js +23 -31
- package/package.json +4 -4
- package/dist/tools/ripgrep.js +0 -230
package/dist/sessions/store.js
CHANGED
|
@@ -4,64 +4,63 @@
|
|
|
4
4
|
// after that node is durable, so a crash leaves either the prior checkpoint or
|
|
5
5
|
// one strictly recoverable mutation -- never an ambiguous partial history.
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
7
|
+
import { lstat, opendir, realpath, rename, rm, } from "node:fs/promises";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { atomicWrite } from "../atomic.js";
|
|
10
|
+
import { BoundedFileError, readBoundedText, stableFileExpectation, } from "../bounded-file.js";
|
|
10
11
|
import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
|
|
11
|
-
import {
|
|
12
|
+
import { assertDirectoryAnchor, captureDirectDirectory, createPrivateDirectory, preparePrivateDirectory, } from "../directory-anchor.js";
|
|
13
|
+
import { sameFileIdentity } from "../file-identity.js";
|
|
14
|
+
import { readStableDirectory } from "../stable-directory.js";
|
|
12
15
|
import { userDataPath } from "../user-data.js";
|
|
16
|
+
import { advanceSessionCatalog, catalogMatches, decodeSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_BYTES, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
|
|
13
17
|
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
|
|
14
|
-
import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
|
|
18
|
+
import { claimLeaseDirectory, createLeaseDirectory, leaseFromGeneration, leaseOwner, leaseToken, pidIsAlive, removeLegacyLeaseExclusive, removeLease, sessionLease, sessionLeaseOwns, } from "./lease.js";
|
|
15
19
|
const DIRECTORY_MODE = 0o700;
|
|
16
20
|
const FILE_MODE = 0o600;
|
|
17
21
|
const MAX_CATALOG_ENTRIES = 4_096;
|
|
18
22
|
const CATALOG_READ_CONCURRENCY = 8;
|
|
19
|
-
const
|
|
23
|
+
const MAX_NODE_READ_CONCURRENCY = 8;
|
|
24
|
+
const MAX_NODE_READ_IN_FLIGHT_BYTES = 64 * 1_024 * 1_024;
|
|
25
|
+
const MAX_SESSION_NODE_BYTES = 192 * 1_024 * 1_024;
|
|
26
|
+
const NODE_READ_CONCURRENCY = Math.max(1, Math.min(MAX_NODE_READ_CONCURRENCY, Math.floor(MAX_NODE_READ_IN_FLIGHT_BYTES / SESSION_FILE_LIMITS.nodeBytes)));
|
|
20
27
|
const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
21
28
|
const NODE_NAME = /^(\d{6})\.json$/;
|
|
22
29
|
const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
|
|
23
30
|
export class DurableSessionStore {
|
|
24
31
|
workspaceRoot;
|
|
25
32
|
workspaceDigest;
|
|
26
|
-
#sessionsRoot;
|
|
27
33
|
#bucket;
|
|
28
|
-
|
|
34
|
+
#sessionsAnchor;
|
|
35
|
+
#bucketAnchor;
|
|
36
|
+
#hooks;
|
|
37
|
+
#leaseScope = Object.freeze({});
|
|
38
|
+
constructor(workspaceRoot, sessionsAnchor, bucketAnchor, hooks) {
|
|
29
39
|
this.workspaceRoot = workspaceRoot;
|
|
30
40
|
this.workspaceDigest = digestWorkspace(workspaceRoot);
|
|
31
|
-
this.#
|
|
32
|
-
this.#
|
|
41
|
+
this.#bucket = bucketAnchor.path;
|
|
42
|
+
this.#sessionsAnchor = sessionsAnchor;
|
|
43
|
+
this.#bucketAnchor = bucketAnchor;
|
|
44
|
+
this.#hooks = hooks;
|
|
33
45
|
}
|
|
34
|
-
static async open(workspaceRoot, sessionsRoot = userDataPath("sessions")) {
|
|
46
|
+
static async open(workspaceRoot, sessionsRoot = userDataPath("sessions"), hooks = {}) {
|
|
35
47
|
const canonical = await realpath(path.resolve(workspaceRoot));
|
|
36
|
-
|
|
48
|
+
const digest = digestWorkspace(canonical);
|
|
49
|
+
const sessionsAnchor = await preparePrivateDirectory(path.resolve(sessionsRoot), "session storage root", DIRECTORY_MODE);
|
|
50
|
+
const bucketAnchor = await preparePrivateDirectory(path.join(sessionsAnchor.path, digest), "workspace session directory", DIRECTORY_MODE);
|
|
51
|
+
return new DurableSessionStore(canonical, sessionsAnchor, bucketAnchor, hooks);
|
|
37
52
|
}
|
|
38
53
|
async list(limit = 32) {
|
|
39
54
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
|
40
55
|
throw new Error("session catalogue limit is invalid");
|
|
41
56
|
}
|
|
42
|
-
|
|
57
|
+
await this.#ensureBucket();
|
|
58
|
+
const names = await catalogNames(this.#bucketAnchor);
|
|
43
59
|
const catalog = [];
|
|
44
60
|
for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
|
|
45
61
|
const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
|
|
46
62
|
.map(async (id) => {
|
|
47
|
-
|
|
48
|
-
const snapshot = await this.load(id);
|
|
49
|
-
const conversation = snapshot.conversation.latestResumable();
|
|
50
|
-
if (conversation === undefined)
|
|
51
|
-
return undefined;
|
|
52
|
-
return {
|
|
53
|
-
id,
|
|
54
|
-
createdAt: snapshot.meta.createdAt,
|
|
55
|
-
updatedAt: snapshot.head.updatedAt,
|
|
56
|
-
turns: selectedTurnCount(conversation),
|
|
57
|
-
preview: firstUserText(conversation),
|
|
58
|
-
active: await this.#leaseIsActive(id),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
// Corrupt or foreign data never becomes a resume candidate.
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
63
|
+
return await this.#catalogEntry(id);
|
|
65
64
|
}));
|
|
66
65
|
catalog.push(...batch.filter((entry) => entry !== undefined));
|
|
67
66
|
}
|
|
@@ -69,16 +68,24 @@ export class DurableSessionStore {
|
|
|
69
68
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
|
|
70
69
|
.slice(0, limit);
|
|
71
70
|
}
|
|
72
|
-
async load(id) {
|
|
71
|
+
async load(id, recoveryLease) {
|
|
73
72
|
assertSessionId(id);
|
|
73
|
+
await this.#ensureBucket();
|
|
74
74
|
const directory = this.#sessionDirectory(id);
|
|
75
|
-
await
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
const directoryAnchor = await captureDirectDirectory(directory, "session directory");
|
|
76
|
+
const nodesAnchor = await captureDirectDirectory(path.join(directory, "nodes"), "session node directory");
|
|
77
|
+
const validateSession = async () => {
|
|
78
|
+
await Promise.all([
|
|
79
|
+
this.#ensureBucket(),
|
|
80
|
+
assertDirectoryAnchor(directoryAnchor),
|
|
81
|
+
assertDirectoryAnchor(nodesAnchor),
|
|
82
|
+
]);
|
|
83
|
+
};
|
|
84
|
+
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
85
|
+
assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
|
|
86
|
+
const persistedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
87
|
+
let head = persistedHead;
|
|
88
|
+
const stored = await readNodes(nodesAnchor, validateSession);
|
|
82
89
|
const ahead = stored.filter((entry) => entry.sequence > head.sequence);
|
|
83
90
|
if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
|
|
84
91
|
throw new Error("session has an ambiguous incomplete checkpoint");
|
|
@@ -105,6 +112,11 @@ export class DurableSessionStore {
|
|
|
105
112
|
if (!replacesHead && !extendsTree) {
|
|
106
113
|
throw new Error("session checkpoint cannot be recovered safely");
|
|
107
114
|
}
|
|
115
|
+
if (recoveryLease === undefined ||
|
|
116
|
+
!sessionLeaseOwns(recoveryLease, id, this.#leaseScope)) {
|
|
117
|
+
throw new Error("session recovery requires exclusive ownership");
|
|
118
|
+
}
|
|
119
|
+
await recoveryLease.assertOwned();
|
|
108
120
|
head = Object.freeze({
|
|
109
121
|
version: SESSION_SCHEMA,
|
|
110
122
|
sequence: candidate.sequence,
|
|
@@ -115,21 +127,34 @@ export class DurableSessionStore {
|
|
|
115
127
|
});
|
|
116
128
|
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
117
129
|
mode: FILE_MODE,
|
|
118
|
-
validate: async () =>
|
|
130
|
+
validate: async (phase) => {
|
|
131
|
+
await validateSession();
|
|
132
|
+
if (phase !== "before-rename")
|
|
133
|
+
return;
|
|
134
|
+
await recoveryLease.assertOwned();
|
|
135
|
+
const current = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
136
|
+
if (!sameSessionHead(current, persistedHead)) {
|
|
137
|
+
throw new Error("session head changed while recovering its checkpoint");
|
|
138
|
+
}
|
|
139
|
+
},
|
|
119
140
|
});
|
|
120
141
|
}
|
|
121
142
|
const nodes = stored.map((entry) => entry.node);
|
|
122
143
|
const conversation = ConversationTree.restore(nodes, head.nodeId);
|
|
123
|
-
|
|
144
|
+
const catalog = sessionCatalog(meta, head, conversation);
|
|
145
|
+
return Object.freeze({ meta, head, conversation, catalog });
|
|
124
146
|
}
|
|
125
|
-
async publish(conversation, claim) {
|
|
147
|
+
async publish(conversation, claim, reservedId) {
|
|
126
148
|
const active = conversation.activeNode;
|
|
127
149
|
if (active === undefined)
|
|
128
150
|
throw new Error("an empty conversation cannot be persisted");
|
|
129
151
|
await this.#ensureBucket();
|
|
130
152
|
const now = new Date().toISOString();
|
|
131
|
-
const id =
|
|
153
|
+
const id = reservedId ?? reserveSessionId(now);
|
|
154
|
+
assertSessionId(id);
|
|
132
155
|
const token = claim === true ? leaseToken() : undefined;
|
|
156
|
+
let leaseGeneration;
|
|
157
|
+
let temporaryAnchor;
|
|
133
158
|
const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
|
|
134
159
|
const target = this.#sessionDirectory(id);
|
|
135
160
|
const meta = Object.freeze({
|
|
@@ -147,44 +172,79 @@ export class DurableSessionStore {
|
|
|
147
172
|
revision: active.revision,
|
|
148
173
|
updatedAt: now,
|
|
149
174
|
});
|
|
175
|
+
const catalog = sessionCatalog(meta, head, conversation);
|
|
150
176
|
try {
|
|
151
|
-
await
|
|
177
|
+
temporaryAnchor = await createPrivateDirectory(temporary, "temporary session directory", DIRECTORY_MODE);
|
|
152
178
|
const nodes = path.join(temporary, "nodes");
|
|
153
|
-
await
|
|
179
|
+
const nodesAnchor = await createPrivateDirectory(nodes, "temporary session node directory", DIRECTORY_MODE);
|
|
180
|
+
const validateTemporary = async () => {
|
|
181
|
+
await this.#ensureBucket();
|
|
182
|
+
await assertDirectoryAnchor(temporaryAnchor);
|
|
183
|
+
await assertDirectoryAnchor(nodesAnchor);
|
|
184
|
+
};
|
|
154
185
|
for (let index = 0; index < conversation.nodes.length; index++) {
|
|
155
186
|
const node = conversation.nodes[index];
|
|
156
|
-
await atomicWrite(path.join(nodes, nodeName(node.id)), encodeNode(node, index + 1, now), { mode: FILE_MODE });
|
|
187
|
+
await atomicWrite(path.join(nodes, nodeName(node.id)), encodeNode(node, index + 1, now), { mode: FILE_MODE, validate: async () => validateTemporary() });
|
|
157
188
|
}
|
|
158
|
-
await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), {
|
|
159
|
-
|
|
189
|
+
await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), {
|
|
190
|
+
mode: FILE_MODE,
|
|
191
|
+
validate: async () => validateTemporary(),
|
|
192
|
+
});
|
|
193
|
+
await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), {
|
|
194
|
+
mode: FILE_MODE,
|
|
195
|
+
validate: async () => validateTemporary(),
|
|
196
|
+
});
|
|
197
|
+
await atomicWrite(path.join(temporary, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate: async () => validateTemporary() });
|
|
160
198
|
if (token !== undefined) {
|
|
161
|
-
await
|
|
199
|
+
leaseGeneration = await createLeaseDirectory(path.join(temporary, "active"), token);
|
|
162
200
|
}
|
|
201
|
+
await validateTemporary();
|
|
163
202
|
await rename(temporary, target);
|
|
203
|
+
await this.#ensureBucket();
|
|
204
|
+
const targetAnchor = await captureDirectDirectory(target, "session directory");
|
|
205
|
+
if (!sameFileIdentity(temporaryAnchor.identity, targetAnchor.identity)) {
|
|
206
|
+
throw new Error("session directory changed while publishing");
|
|
207
|
+
}
|
|
164
208
|
}
|
|
165
209
|
catch (error) {
|
|
166
|
-
await removeTemporaryDirectory(temporary, this.#
|
|
210
|
+
await removeTemporaryDirectory(temporary, this.#bucketAnchor, temporaryAnchor)
|
|
211
|
+
.catch(() => undefined);
|
|
167
212
|
throw error;
|
|
168
213
|
}
|
|
169
|
-
const snapshot = Object.freeze({ meta, head, conversation });
|
|
214
|
+
const snapshot = Object.freeze({ meta, head, conversation, catalog });
|
|
170
215
|
if (token === undefined)
|
|
171
216
|
return snapshot;
|
|
172
|
-
|
|
217
|
+
if (leaseGeneration === undefined)
|
|
218
|
+
throw new Error("session lease was not initialized");
|
|
219
|
+
const lease = sessionLease(id, this.#leaseScope, leaseFromGeneration(path.join(target, "active"), leaseGeneration));
|
|
173
220
|
return Object.freeze({ ...snapshot, lease });
|
|
174
221
|
}
|
|
175
|
-
async checkpoint(previous, conversation) {
|
|
222
|
+
async checkpoint(previous, conversation, lease) {
|
|
176
223
|
assertSnapshot(previous, this.workspaceRoot, this.workspaceDigest);
|
|
177
224
|
const id = previous.meta.id;
|
|
225
|
+
if (!sessionLeaseOwns(lease, id, this.#leaseScope)) {
|
|
226
|
+
throw new Error("session checkpoint requires exclusive ownership");
|
|
227
|
+
}
|
|
228
|
+
await lease.assertOwned();
|
|
178
229
|
const directory = this.#sessionDirectory(id);
|
|
179
|
-
await
|
|
230
|
+
const directoryAnchor = await this.#sessionAnchor(id);
|
|
180
231
|
const nodesDirectory = path.join(directory, "nodes");
|
|
232
|
+
const nodesAnchor = await captureDirectDirectory(nodesDirectory, "session node directory");
|
|
181
233
|
const validateNodesDirectory = async () => {
|
|
182
|
-
await
|
|
183
|
-
|
|
234
|
+
await Promise.all([
|
|
235
|
+
lease.assertOwned(),
|
|
236
|
+
this.#ensureBucket(),
|
|
237
|
+
assertDirectoryAnchor(directoryAnchor),
|
|
238
|
+
assertDirectoryAnchor(nodesAnchor),
|
|
239
|
+
]);
|
|
184
240
|
};
|
|
185
241
|
await validateNodesDirectory();
|
|
186
|
-
const
|
|
187
|
-
|
|
242
|
+
const headFile = path.join(directory, "head.json");
|
|
243
|
+
const headExpectation = stableFileExpectation(await lstat(headFile, { bigint: true }));
|
|
244
|
+
await validateNodesDirectory();
|
|
245
|
+
const currentHead = decodeHead(await readJson(headFile, SESSION_FILE_LIMITS.metadataBytes, headExpectation));
|
|
246
|
+
await validateNodesDirectory();
|
|
247
|
+
if (!sameSessionHead(currentHead, previous.head)) {
|
|
188
248
|
throw new Error("session head changed after its verified snapshot");
|
|
189
249
|
}
|
|
190
250
|
const active = conversation.activeNode;
|
|
@@ -216,77 +276,262 @@ export class DurableSessionStore {
|
|
|
216
276
|
revision: active.revision,
|
|
217
277
|
updatedAt: now,
|
|
218
278
|
});
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const handle = await open(file, "wx", FILE_MODE);
|
|
279
|
+
const catalog = advanceSessionCatalog(previous.catalog, previous.meta, head, conversation);
|
|
280
|
+
const checkpointToken = leaseToken();
|
|
281
|
+
const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
|
|
282
|
+
await this.#hooks.beforeCheckpointLease?.();
|
|
283
|
+
await validateNodesDirectory();
|
|
284
|
+
const checkpointLease = await claimLeaseDirectory(checkpointFile, checkpointToken);
|
|
285
|
+
if (checkpointLease === undefined)
|
|
286
|
+
throw new Error("session checkpoint is already active");
|
|
287
|
+
let primaryFailure;
|
|
288
|
+
try {
|
|
289
|
+
await this.#hooks.afterCheckpointLease?.();
|
|
290
|
+
const assertBaseHead = async () => {
|
|
291
|
+
await Promise.all([checkpointLease.assertOwned(), validateNodesDirectory()]);
|
|
292
|
+
let verified;
|
|
234
293
|
try {
|
|
235
|
-
await
|
|
236
|
-
await handle.sync();
|
|
294
|
+
verified = decodeHead(await readJson(headFile, SESSION_FILE_LIMITS.metadataBytes, headExpectation));
|
|
237
295
|
}
|
|
238
|
-
|
|
239
|
-
|
|
296
|
+
catch (error) {
|
|
297
|
+
throw new Error("session head changed after its verified snapshot", { cause: error });
|
|
298
|
+
}
|
|
299
|
+
await Promise.all([checkpointLease.assertOwned(), validateNodesDirectory()]);
|
|
300
|
+
if (!sameSessionHead(verified, previous.head)) {
|
|
301
|
+
throw new Error("session head changed after its verified snapshot");
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
await assertBaseHead();
|
|
305
|
+
if (extendsTree) {
|
|
306
|
+
await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)), validateNodesDirectory);
|
|
307
|
+
}
|
|
308
|
+
await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), {
|
|
309
|
+
mode: FILE_MODE,
|
|
310
|
+
validate: async (phase) => {
|
|
311
|
+
await assertBaseHead();
|
|
312
|
+
if (extendsTree && phase === "before-rename") {
|
|
313
|
+
await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)), validateNodesDirectory);
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
318
|
+
mode: FILE_MODE,
|
|
319
|
+
validate: async () => assertBaseHead(),
|
|
320
|
+
});
|
|
321
|
+
await this.#writeCatalog(previous.meta.id, catalog, checkpointToken).catch(() => undefined);
|
|
322
|
+
return Object.freeze({ meta: previous.meta, head, conversation, catalog });
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
primaryFailure = error;
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
try {
|
|
330
|
+
if (!(await checkpointLease.release()) && primaryFailure === undefined) {
|
|
331
|
+
throw new Error("session checkpoint ownership was lost");
|
|
240
332
|
}
|
|
241
|
-
return sessionLease(id, file, token);
|
|
242
333
|
}
|
|
243
334
|
catch (error) {
|
|
244
|
-
if (
|
|
335
|
+
if (primaryFailure === undefined)
|
|
245
336
|
throw error;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async claim(id) {
|
|
341
|
+
assertSessionId(id);
|
|
342
|
+
const directory = this.#sessionDirectory(id);
|
|
343
|
+
const directoryAnchor = await this.#sessionAnchor(id);
|
|
344
|
+
const validateDirectory = async () => {
|
|
345
|
+
await this.#assertSessionAnchor(directoryAnchor);
|
|
346
|
+
};
|
|
347
|
+
await validateDirectory();
|
|
348
|
+
const file = path.join(directory, "active");
|
|
349
|
+
const previous = await leaseOwner(file);
|
|
350
|
+
await validateDirectory();
|
|
351
|
+
if (previous?.legacy === true) {
|
|
352
|
+
if (pidIsAlive(previous.pid)) {
|
|
353
|
+
throw new Error("session is already open in an older Jecode process");
|
|
354
|
+
}
|
|
355
|
+
throw new Error("session has a stale legacy active marker; close older Jecode processes and remove it before retrying");
|
|
356
|
+
}
|
|
357
|
+
const token = leaseToken();
|
|
358
|
+
const lease = await claimLeaseDirectory(file, token);
|
|
359
|
+
if (lease === undefined) {
|
|
360
|
+
throw new Error("session is already open in another Jecode process");
|
|
361
|
+
}
|
|
362
|
+
const owned = sessionLease(id, this.#leaseScope, lease);
|
|
363
|
+
const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
|
|
364
|
+
try {
|
|
365
|
+
await owned.assertOwned();
|
|
366
|
+
await validateDirectory();
|
|
367
|
+
const checkpoint = await leaseOwner(checkpointFile);
|
|
368
|
+
if (checkpoint !== undefined && pidIsAlive(checkpoint.pid)) {
|
|
369
|
+
throw new Error("session has a live checkpoint from another Jecode process");
|
|
370
|
+
}
|
|
371
|
+
if (checkpoint?.legacy === true) {
|
|
372
|
+
if (!await removeLegacyLeaseExclusive(checkpointFile, checkpoint.token, owned)) {
|
|
373
|
+
throw new Error("session legacy checkpoint changed during migration");
|
|
252
374
|
}
|
|
253
375
|
}
|
|
376
|
+
else if (checkpoint !== undefined &&
|
|
377
|
+
!await removeLease(checkpointFile, checkpoint.token)) {
|
|
378
|
+
throw new Error("session checkpoint changed during recovery");
|
|
379
|
+
}
|
|
380
|
+
await owned.assertOwned();
|
|
381
|
+
await validateDirectory();
|
|
254
382
|
}
|
|
255
|
-
|
|
383
|
+
catch (error) {
|
|
384
|
+
await owned.close().catch(() => undefined);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
return owned;
|
|
256
388
|
}
|
|
257
389
|
#sessionDirectory(id) {
|
|
258
390
|
return path.join(this.#bucket, id);
|
|
259
391
|
}
|
|
392
|
+
async #sessionAnchor(id) {
|
|
393
|
+
await this.#ensureBucket();
|
|
394
|
+
return captureDirectDirectory(this.#sessionDirectory(id), "session directory");
|
|
395
|
+
}
|
|
396
|
+
async #assertSessionAnchor(anchor) {
|
|
397
|
+
await Promise.all([this.#ensureBucket(), assertDirectoryAnchor(anchor)]);
|
|
398
|
+
}
|
|
260
399
|
async #ensureBucket() {
|
|
261
|
-
await
|
|
262
|
-
|
|
400
|
+
await Promise.all([
|
|
401
|
+
assertDirectoryAnchor(this.#sessionsAnchor),
|
|
402
|
+
assertDirectoryAnchor(this.#bucketAnchor),
|
|
403
|
+
]);
|
|
263
404
|
}
|
|
264
|
-
async #leaseIsActive(id) {
|
|
405
|
+
async #leaseIsActive(id, directory) {
|
|
406
|
+
if (directory !== undefined)
|
|
407
|
+
await this.#assertSessionAnchor(directory);
|
|
408
|
+
else
|
|
409
|
+
await this.#ensureBucket();
|
|
265
410
|
const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
|
|
411
|
+
if (directory !== undefined)
|
|
412
|
+
await this.#assertSessionAnchor(directory);
|
|
266
413
|
return owner !== undefined && pidIsAlive(owner.pid);
|
|
267
414
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
415
|
+
async #catalogEntry(id) {
|
|
416
|
+
try {
|
|
417
|
+
const directory = this.#sessionDirectory(id);
|
|
418
|
+
const directoryAnchor = await this.#sessionAnchor(id);
|
|
419
|
+
const validateDirectory = async () => {
|
|
420
|
+
await this.#assertSessionAnchor(directoryAnchor);
|
|
421
|
+
};
|
|
422
|
+
const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
|
|
423
|
+
// A second head read closes the only useful race: a checkpoint landing
|
|
424
|
+
// between the small record reads. A changing marker gets one retry.
|
|
425
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
426
|
+
await validateDirectory();
|
|
427
|
+
const checkpointBefore = await leaseOwner(checkpointFile);
|
|
428
|
+
try {
|
|
429
|
+
const [metaValue, headValue, catalogValue] = await Promise.all([
|
|
430
|
+
readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
|
|
431
|
+
readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
|
|
432
|
+
readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES, undefined, validateDirectory),
|
|
433
|
+
]);
|
|
434
|
+
const meta = decodeMeta(metaValue);
|
|
435
|
+
const head = decodeHead(headValue);
|
|
436
|
+
const storedCatalog = decodeSessionCatalog(catalogValue);
|
|
437
|
+
assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
|
|
438
|
+
const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
|
|
439
|
+
await validateDirectory();
|
|
440
|
+
const checkpointAfter = await leaseOwner(checkpointFile);
|
|
441
|
+
if (!sameLease(checkpointBefore, checkpointAfter))
|
|
442
|
+
continue;
|
|
443
|
+
if (!sameSessionHead(head, confirmedHead) ||
|
|
444
|
+
!catalogMatches(storedCatalog, meta, head))
|
|
445
|
+
break;
|
|
446
|
+
if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
|
|
447
|
+
break;
|
|
448
|
+
const active = await this.#leaseIsActive(id, directoryAnchor) ||
|
|
449
|
+
checkpointAfter !== undefined;
|
|
450
|
+
return catalogEntry(storedCatalog, active);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Missing, stale, or malformed summaries are rebuilt only while the
|
|
457
|
+
// session is idle. Selecting a session still performs this strict load.
|
|
458
|
+
const checkpoint = await leaseOwner(checkpointFile);
|
|
459
|
+
if (await this.#leaseIsActive(id, directoryAnchor) ||
|
|
460
|
+
(checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
|
|
461
|
+
return undefined;
|
|
462
|
+
const repairLease = await this.claim(id);
|
|
463
|
+
let snapshot;
|
|
464
|
+
try {
|
|
465
|
+
snapshot = await this.load(id, repairLease);
|
|
466
|
+
await this.#writeCatalog(id, snapshot.catalog, checkpoint?.legacy === true ? undefined : checkpoint?.token).catch(() => undefined);
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
await repairLease.close();
|
|
470
|
+
}
|
|
471
|
+
const currentCheckpoint = await leaseOwner(checkpointFile);
|
|
472
|
+
const active = await this.#leaseIsActive(id, directoryAnchor) ||
|
|
473
|
+
(currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
|
|
474
|
+
return catalogEntry(snapshot.catalog, active);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// Corrupt, unsafe, active-without-a-summary, or foreign data never
|
|
478
|
+
// becomes a resume candidate.
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
277
481
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
482
|
+
async #writeCatalog(id, catalog, checkpointToken) {
|
|
483
|
+
const directory = this.#sessionDirectory(id);
|
|
484
|
+
const directoryAnchor = await this.#sessionAnchor(id);
|
|
485
|
+
const validateDirectory = async () => {
|
|
486
|
+
await this.#assertSessionAnchor(directoryAnchor);
|
|
487
|
+
};
|
|
488
|
+
const validate = async () => {
|
|
489
|
+
await validateDirectory();
|
|
490
|
+
const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
|
|
491
|
+
if (!sameSessionHead(currentHead, catalog.head)) {
|
|
492
|
+
throw new Error("session head changed while updating its catalogue");
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
|
|
496
|
+
if (checkpointToken !== undefined) {
|
|
497
|
+
await validateDirectory();
|
|
498
|
+
await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
|
|
499
|
+
await validateDirectory();
|
|
285
500
|
}
|
|
286
|
-
if (entry.isDirectory() && SESSION_NAME.test(entry.name))
|
|
287
|
-
names.push(entry.name);
|
|
288
501
|
}
|
|
289
|
-
|
|
502
|
+
}
|
|
503
|
+
async function catalogNames(directory) {
|
|
504
|
+
await assertDirectoryAnchor(directory);
|
|
505
|
+
const inspected = await readStableDirectory(directory.path, directory.path, {
|
|
506
|
+
maxEntries: MAX_CATALOG_ENTRIES + 1,
|
|
507
|
+
});
|
|
508
|
+
if (inspected.capped || inspected.entries.length > MAX_CATALOG_ENTRIES) {
|
|
509
|
+
throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
|
|
510
|
+
}
|
|
511
|
+
return inspected.entries
|
|
512
|
+
.filter((entry) => entry.kind === "directory" && SESSION_NAME.test(entry.name))
|
|
513
|
+
.map((entry) => entry.name)
|
|
514
|
+
.sort((left, right) => right.localeCompare(left));
|
|
515
|
+
}
|
|
516
|
+
function catalogEntry(catalog, active) {
|
|
517
|
+
if (catalog.resumeNodeId === 0)
|
|
518
|
+
return undefined;
|
|
519
|
+
return Object.freeze({
|
|
520
|
+
id: catalog.id,
|
|
521
|
+
createdAt: catalog.createdAt,
|
|
522
|
+
updatedAt: catalog.head.updatedAt,
|
|
523
|
+
turns: catalog.turns,
|
|
524
|
+
preview: catalog.preview,
|
|
525
|
+
active,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
function sameLease(left, right) {
|
|
529
|
+
return left?.token === right?.token && left?.legacy === right?.legacy;
|
|
530
|
+
}
|
|
531
|
+
function assertSessionWorkspace(meta, id, workspaceRoot, workspaceDigest) {
|
|
532
|
+
if (meta.id !== id || meta.workspaceDigest !== workspaceDigest ||
|
|
533
|
+
workspaceKey(meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
534
|
+
throw new Error("session belongs to a different workspace");
|
|
290
535
|
}
|
|
291
536
|
function assertSharedNodes(previous, next, replacedId) {
|
|
292
537
|
for (const node of previous.nodes) {
|
|
@@ -301,6 +546,7 @@ function assertSharedNodes(previous, next, replacedId) {
|
|
|
301
546
|
function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
302
547
|
encodeMeta(snapshot.meta);
|
|
303
548
|
encodeHead(snapshot.head);
|
|
549
|
+
encodeSessionCatalog(snapshot.catalog);
|
|
304
550
|
assertSessionId(snapshot.meta.id);
|
|
305
551
|
if (snapshot.meta.workspaceDigest !== workspaceDigest ||
|
|
306
552
|
workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
@@ -312,48 +558,76 @@ function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
|
312
558
|
active.revision !== snapshot.head.revision ||
|
|
313
559
|
snapshot.head.sequence < snapshot.conversation.nodes.length)
|
|
314
560
|
throw new Error("session snapshot does not match its verified head");
|
|
561
|
+
if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
|
|
562
|
+
throw new Error("session snapshot does not match its verified catalogue");
|
|
563
|
+
}
|
|
315
564
|
}
|
|
316
|
-
function
|
|
317
|
-
|
|
318
|
-
left.sequence === right.sequence &&
|
|
319
|
-
left.nodeId === right.nodeId &&
|
|
320
|
-
left.parentId === right.parentId &&
|
|
321
|
-
left.revision === right.revision &&
|
|
322
|
-
left.updatedAt === right.updatedAt;
|
|
323
|
-
}
|
|
324
|
-
async function assertMissingNode(file) {
|
|
565
|
+
async function assertMissingNode(file, validate) {
|
|
566
|
+
await validate?.();
|
|
325
567
|
try {
|
|
326
568
|
await lstat(file);
|
|
327
569
|
}
|
|
328
570
|
catch (error) {
|
|
329
|
-
if (error.code === "ENOENT")
|
|
571
|
+
if (error.code === "ENOENT") {
|
|
572
|
+
await validate?.();
|
|
330
573
|
return;
|
|
574
|
+
}
|
|
331
575
|
throw error;
|
|
332
576
|
}
|
|
577
|
+
await validate?.();
|
|
333
578
|
throw new Error("session has an incomplete node outside its verified snapshot");
|
|
334
579
|
}
|
|
335
|
-
async function readNodes(directory) {
|
|
336
|
-
await
|
|
337
|
-
|
|
338
|
-
const names =
|
|
339
|
-
|
|
580
|
+
async function readNodes(directory, validate) {
|
|
581
|
+
await validate();
|
|
582
|
+
await assertDirectoryAnchor(directory);
|
|
583
|
+
const names = [];
|
|
584
|
+
let entries = 0;
|
|
585
|
+
const handle = await opendir(directory.path);
|
|
586
|
+
for await (const entry of handle) {
|
|
587
|
+
entries++;
|
|
588
|
+
if (entries > CONVERSATION_LIMITS.nodes + 64) {
|
|
589
|
+
throw new Error("session node directory contains unsupported data");
|
|
590
|
+
}
|
|
591
|
+
if (!entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name))) {
|
|
592
|
+
throw new Error("session node directory contains unsupported data");
|
|
593
|
+
}
|
|
594
|
+
if (NODE_NAME.test(entry.name))
|
|
595
|
+
names.push(entry.name);
|
|
596
|
+
}
|
|
597
|
+
await validate();
|
|
598
|
+
await assertDirectoryAnchor(directory);
|
|
599
|
+
names.sort();
|
|
340
600
|
if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
|
|
341
601
|
throw new Error("session has an invalid conversation size");
|
|
342
602
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
603
|
+
const files = [];
|
|
604
|
+
let storedBytes = 0;
|
|
605
|
+
for (let index = 0; index < names.length; index++) {
|
|
606
|
+
const name = names[index];
|
|
607
|
+
const id = Number(NODE_NAME.exec(name)?.[1]);
|
|
608
|
+
if (id !== index + 1) {
|
|
609
|
+
throw new Error("session conversation nodes are not contiguous");
|
|
610
|
+
}
|
|
611
|
+
const details = await lstat(path.join(directory.path, name), { bigint: true });
|
|
612
|
+
if (details.isSymbolicLink() || !details.isFile() || details.size < 0n ||
|
|
613
|
+
details.size > BigInt(SESSION_FILE_LIMITS.nodeBytes))
|
|
614
|
+
throw new Error("session node file is unsafe or too large");
|
|
615
|
+
storedBytes += Number(details.size);
|
|
616
|
+
if (storedBytes > MAX_SESSION_NODE_BYTES) {
|
|
617
|
+
throw new Error("session node files exceed their aggregate storage limit");
|
|
618
|
+
}
|
|
619
|
+
files.push({ name, id, expected: stableFileExpectation(details) });
|
|
346
620
|
}
|
|
621
|
+
await validate();
|
|
347
622
|
const stored = [];
|
|
348
623
|
const sequences = new Set();
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const entry = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
|
|
624
|
+
let messageCodeUnits = 0;
|
|
625
|
+
let transcriptCodeUnits = 0;
|
|
626
|
+
let contextCodeUnits = 0;
|
|
627
|
+
for (let start = 0; start < files.length; start += NODE_READ_CONCURRENCY) {
|
|
628
|
+
const decoded = await Promise.all(files.slice(start, start + NODE_READ_CONCURRENCY)
|
|
629
|
+
.map(async ({ name, id, expected }) => {
|
|
630
|
+
const entry = decodeNode(await readJson(path.join(directory.path, name), SESSION_FILE_LIMITS.nodeBytes, expected));
|
|
357
631
|
if (entry.node.id !== id) {
|
|
358
632
|
throw new Error("session conversation node identity is invalid");
|
|
359
633
|
}
|
|
@@ -363,78 +637,72 @@ async function readNodes(directory) {
|
|
|
363
637
|
if (sequences.has(entry.sequence)) {
|
|
364
638
|
throw new Error("session conversation node identity is invalid");
|
|
365
639
|
}
|
|
640
|
+
messageCodeUnits += JSON.stringify(entry.node.messages).length;
|
|
641
|
+
transcriptCodeUnits += JSON.stringify(entry.node.blocks).length;
|
|
642
|
+
contextCodeUnits += entry.node.context?.summary.length ?? 0;
|
|
643
|
+
if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits ||
|
|
644
|
+
transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits ||
|
|
645
|
+
contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
|
|
646
|
+
throw new Error("session conversation exceeds its aggregate limit");
|
|
647
|
+
}
|
|
366
648
|
sequences.add(entry.sequence);
|
|
367
649
|
stored.push(entry);
|
|
368
650
|
}
|
|
369
651
|
}
|
|
652
|
+
await validate();
|
|
370
653
|
return stored;
|
|
371
654
|
}
|
|
372
|
-
async function readJson(file, limit) {
|
|
373
|
-
const details = await lstat(file);
|
|
374
|
-
if (details.isSymbolicLink() || !details.isFile() || details.size > limit) {
|
|
375
|
-
throw new Error("session file is unsafe or too large");
|
|
376
|
-
}
|
|
377
|
-
try {
|
|
378
|
-
return JSON.parse(await readFile(file, "utf8"));
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
throw new Error("session file is not valid JSON");
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
async function assertDirectory(directory) {
|
|
385
|
-
const details = await lstat(directory);
|
|
386
|
-
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
387
|
-
throw new Error("session path is not a direct directory");
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
async function makePrivateDirectory(directory) {
|
|
391
|
-
await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
392
|
-
await assertDirectory(directory);
|
|
393
|
-
if (process.platform !== "win32")
|
|
394
|
-
await chmod(directory, DIRECTORY_MODE);
|
|
395
|
-
}
|
|
396
|
-
async function directoryEntries(directory) {
|
|
655
|
+
async function readJson(file, limit, expected, validate) {
|
|
397
656
|
try {
|
|
398
|
-
await
|
|
399
|
-
|
|
657
|
+
return JSON.parse(await readBoundedText(file, limit, {
|
|
658
|
+
label: "session file",
|
|
659
|
+
expected,
|
|
660
|
+
validate,
|
|
661
|
+
}));
|
|
400
662
|
}
|
|
401
663
|
catch (error) {
|
|
402
|
-
if (error
|
|
403
|
-
|
|
664
|
+
if (error instanceof SyntaxError) {
|
|
665
|
+
throw new Error("session file is not valid JSON");
|
|
666
|
+
}
|
|
667
|
+
if (error instanceof BoundedFileError) {
|
|
668
|
+
throw new Error("session file is unsafe or too large, or changed while opening");
|
|
669
|
+
}
|
|
404
670
|
throw error;
|
|
405
671
|
}
|
|
406
672
|
}
|
|
407
|
-
async function removeTemporaryDirectory(directory, bucket) {
|
|
408
|
-
const relative = path.relative(bucket, directory);
|
|
673
|
+
async function removeTemporaryDirectory(directory, bucket, expected) {
|
|
674
|
+
const relative = path.relative(bucket.path, directory);
|
|
409
675
|
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
|
|
410
676
|
!path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
|
|
411
677
|
throw new Error("refusing to remove an unverified session directory");
|
|
412
|
-
await
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
let id = conversation.activeNodeId;
|
|
417
|
-
while (id !== 0) {
|
|
418
|
-
count++;
|
|
419
|
-
id = conversation.node(id)?.parentId ?? 0;
|
|
678
|
+
await assertDirectoryAnchor(bucket);
|
|
679
|
+
let observed;
|
|
680
|
+
try {
|
|
681
|
+
observed = await captureDirectDirectory(directory, "temporary session directory");
|
|
420
682
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
683
|
+
catch (error) {
|
|
684
|
+
if (error.code === "ENOENT")
|
|
685
|
+
return;
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
if (expected !== undefined &&
|
|
689
|
+
!sameFileIdentity(expected.identity, observed.identity))
|
|
690
|
+
throw new Error("refusing to remove a replaced session directory");
|
|
691
|
+
const quarantine = path.join(bucket.path, `.discard-${process.pid}-${randomUUID()}.tmp`);
|
|
692
|
+
await assertDirectoryAnchor(bucket);
|
|
693
|
+
await rename(directory, quarantine);
|
|
694
|
+
const moved = await captureDirectDirectory(quarantine, "discarded session directory");
|
|
695
|
+
if (!sameFileIdentity(observed.identity, moved.identity)) {
|
|
696
|
+
throw new Error("session cleanup target changed during quarantine");
|
|
431
697
|
}
|
|
432
|
-
|
|
698
|
+
await assertDirectoryAnchor(bucket);
|
|
699
|
+
await assertDirectoryAnchor(moved);
|
|
700
|
+
await rm(quarantine, { recursive: true });
|
|
433
701
|
}
|
|
434
702
|
function nodeName(id) {
|
|
435
703
|
return `${String(id).padStart(6, "0")}.json`;
|
|
436
704
|
}
|
|
437
|
-
function
|
|
705
|
+
export function reserveSessionId(now = new Date().toISOString()) {
|
|
438
706
|
return `${now.replace(/[-:.]/g, "").replace("Z", "Z")}-${randomUUID()}`;
|
|
439
707
|
}
|
|
440
708
|
function digestWorkspace(workspaceRoot) {
|