@giovannijecha/jecode 0.8.4 → 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 +8 -6
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch.js +37 -4
- package/dist/bounded-file.js +212 -0
- package/dist/commands.js +2 -0
- package/dist/config.js +2 -1
- 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 +11 -5
- package/dist/provider-errors.js +37 -4
- 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 +82 -23
- 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 +26 -2
- package/dist/providers/openai.js +51 -24
- package/dist/providers/sse.js +52 -8
- package/dist/request-identity.js +32 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/store.js +366 -142
- 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 +65 -100
- 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 +3 -4
- package/dist/tools/ripgrep.js +0 -230
package/dist/sessions/store.js
CHANGED
|
@@ -4,42 +4,58 @@
|
|
|
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";
|
|
12
|
+
import { assertDirectoryAnchor, captureDirectDirectory, createPrivateDirectory, preparePrivateDirectory, } from "../directory-anchor.js";
|
|
13
|
+
import { sameFileIdentity } from "../file-identity.js";
|
|
14
|
+
import { readStableDirectory } from "../stable-directory.js";
|
|
11
15
|
import { userDataPath } from "../user-data.js";
|
|
12
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)
|
|
@@ -52,14 +68,24 @@ export class DurableSessionStore {
|
|
|
52
68
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
|
|
53
69
|
.slice(0, limit);
|
|
54
70
|
}
|
|
55
|
-
async load(id) {
|
|
71
|
+
async load(id, recoveryLease) {
|
|
56
72
|
assertSessionId(id);
|
|
73
|
+
await this.#ensureBucket();
|
|
57
74
|
const directory = this.#sessionDirectory(id);
|
|
58
|
-
await
|
|
59
|
-
const
|
|
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));
|
|
60
85
|
assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
|
|
61
|
-
|
|
62
|
-
|
|
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);
|
|
63
89
|
const ahead = stored.filter((entry) => entry.sequence > head.sequence);
|
|
64
90
|
if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
|
|
65
91
|
throw new Error("session has an ambiguous incomplete checkpoint");
|
|
@@ -86,6 +112,11 @@ export class DurableSessionStore {
|
|
|
86
112
|
if (!replacesHead && !extendsTree) {
|
|
87
113
|
throw new Error("session checkpoint cannot be recovered safely");
|
|
88
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();
|
|
89
120
|
head = Object.freeze({
|
|
90
121
|
version: SESSION_SCHEMA,
|
|
91
122
|
sequence: candidate.sequence,
|
|
@@ -96,7 +127,16 @@ export class DurableSessionStore {
|
|
|
96
127
|
});
|
|
97
128
|
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
98
129
|
mode: FILE_MODE,
|
|
99
|
-
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
|
+
},
|
|
100
140
|
});
|
|
101
141
|
}
|
|
102
142
|
const nodes = stored.map((entry) => entry.node);
|
|
@@ -104,14 +144,17 @@ export class DurableSessionStore {
|
|
|
104
144
|
const catalog = sessionCatalog(meta, head, conversation);
|
|
105
145
|
return Object.freeze({ meta, head, conversation, catalog });
|
|
106
146
|
}
|
|
107
|
-
async publish(conversation, claim) {
|
|
147
|
+
async publish(conversation, claim, reservedId) {
|
|
108
148
|
const active = conversation.activeNode;
|
|
109
149
|
if (active === undefined)
|
|
110
150
|
throw new Error("an empty conversation cannot be persisted");
|
|
111
151
|
await this.#ensureBucket();
|
|
112
152
|
const now = new Date().toISOString();
|
|
113
|
-
const id =
|
|
153
|
+
const id = reservedId ?? reserveSessionId(now);
|
|
154
|
+
assertSessionId(id);
|
|
114
155
|
const token = claim === true ? leaseToken() : undefined;
|
|
156
|
+
let leaseGeneration;
|
|
157
|
+
let temporaryAnchor;
|
|
115
158
|
const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
|
|
116
159
|
const target = this.#sessionDirectory(id);
|
|
117
160
|
const meta = Object.freeze({
|
|
@@ -131,43 +174,76 @@ export class DurableSessionStore {
|
|
|
131
174
|
});
|
|
132
175
|
const catalog = sessionCatalog(meta, head, conversation);
|
|
133
176
|
try {
|
|
134
|
-
await
|
|
177
|
+
temporaryAnchor = await createPrivateDirectory(temporary, "temporary session directory", DIRECTORY_MODE);
|
|
135
178
|
const nodes = path.join(temporary, "nodes");
|
|
136
|
-
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
|
+
};
|
|
137
185
|
for (let index = 0; index < conversation.nodes.length; index++) {
|
|
138
186
|
const node = conversation.nodes[index];
|
|
139
|
-
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() });
|
|
140
188
|
}
|
|
141
|
-
await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), {
|
|
142
|
-
|
|
143
|
-
|
|
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() });
|
|
144
198
|
if (token !== undefined) {
|
|
145
|
-
await
|
|
199
|
+
leaseGeneration = await createLeaseDirectory(path.join(temporary, "active"), token);
|
|
146
200
|
}
|
|
201
|
+
await validateTemporary();
|
|
147
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
|
+
}
|
|
148
208
|
}
|
|
149
209
|
catch (error) {
|
|
150
|
-
await removeTemporaryDirectory(temporary, this.#
|
|
210
|
+
await removeTemporaryDirectory(temporary, this.#bucketAnchor, temporaryAnchor)
|
|
211
|
+
.catch(() => undefined);
|
|
151
212
|
throw error;
|
|
152
213
|
}
|
|
153
214
|
const snapshot = Object.freeze({ meta, head, conversation, catalog });
|
|
154
215
|
if (token === undefined)
|
|
155
216
|
return snapshot;
|
|
156
|
-
|
|
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));
|
|
157
220
|
return Object.freeze({ ...snapshot, lease });
|
|
158
221
|
}
|
|
159
|
-
async checkpoint(previous, conversation) {
|
|
222
|
+
async checkpoint(previous, conversation, lease) {
|
|
160
223
|
assertSnapshot(previous, this.workspaceRoot, this.workspaceDigest);
|
|
161
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();
|
|
162
229
|
const directory = this.#sessionDirectory(id);
|
|
163
|
-
await
|
|
230
|
+
const directoryAnchor = await this.#sessionAnchor(id);
|
|
164
231
|
const nodesDirectory = path.join(directory, "nodes");
|
|
232
|
+
const nodesAnchor = await captureDirectDirectory(nodesDirectory, "session node directory");
|
|
165
233
|
const validateNodesDirectory = async () => {
|
|
166
|
-
await
|
|
167
|
-
|
|
234
|
+
await Promise.all([
|
|
235
|
+
lease.assertOwned(),
|
|
236
|
+
this.#ensureBucket(),
|
|
237
|
+
assertDirectoryAnchor(directoryAnchor),
|
|
238
|
+
assertDirectoryAnchor(nodesAnchor),
|
|
239
|
+
]);
|
|
168
240
|
};
|
|
169
241
|
await validateNodesDirectory();
|
|
170
|
-
const
|
|
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();
|
|
171
247
|
if (!sameSessionHead(currentHead, previous.head)) {
|
|
172
248
|
throw new Error("session head changed after its verified snapshot");
|
|
173
249
|
}
|
|
@@ -202,80 +278,165 @@ export class DurableSessionStore {
|
|
|
202
278
|
});
|
|
203
279
|
const catalog = advanceSessionCatalog(previous.catalog, previous.meta, head, conversation);
|
|
204
280
|
const checkpointToken = leaseToken();
|
|
205
|
-
|
|
206
|
-
await
|
|
207
|
-
await
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
async claim(id) {
|
|
218
|
-
assertSessionId(id);
|
|
219
|
-
await assertDirectory(this.#sessionDirectory(id));
|
|
220
|
-
const file = path.join(this.#sessionDirectory(id), "active");
|
|
221
|
-
const token = leaseToken();
|
|
222
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
223
|
-
try {
|
|
224
|
-
const handle = await open(file, "wx", FILE_MODE);
|
|
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;
|
|
225
293
|
try {
|
|
226
|
-
await
|
|
227
|
-
|
|
294
|
+
verified = decodeHead(await readJson(headFile, SESSION_FILE_LIMITS.metadataBytes, headExpectation));
|
|
295
|
+
}
|
|
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");
|
|
228
302
|
}
|
|
229
|
-
|
|
230
|
-
|
|
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");
|
|
231
332
|
}
|
|
232
|
-
return sessionLease(id, file, token);
|
|
233
333
|
}
|
|
234
334
|
catch (error) {
|
|
235
|
-
if (
|
|
335
|
+
if (primaryFailure === undefined)
|
|
236
336
|
throw error;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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");
|
|
243
374
|
}
|
|
244
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();
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
await owned.close().catch(() => undefined);
|
|
385
|
+
throw error;
|
|
245
386
|
}
|
|
246
|
-
|
|
387
|
+
return owned;
|
|
247
388
|
}
|
|
248
389
|
#sessionDirectory(id) {
|
|
249
390
|
return path.join(this.#bucket, id);
|
|
250
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
|
+
}
|
|
251
399
|
async #ensureBucket() {
|
|
252
|
-
await
|
|
253
|
-
|
|
400
|
+
await Promise.all([
|
|
401
|
+
assertDirectoryAnchor(this.#sessionsAnchor),
|
|
402
|
+
assertDirectoryAnchor(this.#bucketAnchor),
|
|
403
|
+
]);
|
|
254
404
|
}
|
|
255
|
-
async #leaseIsActive(id) {
|
|
405
|
+
async #leaseIsActive(id, directory) {
|
|
406
|
+
if (directory !== undefined)
|
|
407
|
+
await this.#assertSessionAnchor(directory);
|
|
408
|
+
else
|
|
409
|
+
await this.#ensureBucket();
|
|
256
410
|
const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
|
|
411
|
+
if (directory !== undefined)
|
|
412
|
+
await this.#assertSessionAnchor(directory);
|
|
257
413
|
return owner !== undefined && pidIsAlive(owner.pid);
|
|
258
414
|
}
|
|
259
415
|
async #catalogEntry(id) {
|
|
260
416
|
try {
|
|
261
417
|
const directory = this.#sessionDirectory(id);
|
|
262
|
-
await
|
|
418
|
+
const directoryAnchor = await this.#sessionAnchor(id);
|
|
419
|
+
const validateDirectory = async () => {
|
|
420
|
+
await this.#assertSessionAnchor(directoryAnchor);
|
|
421
|
+
};
|
|
263
422
|
const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
|
|
264
423
|
// A second head read closes the only useful race: a checkpoint landing
|
|
265
424
|
// between the small record reads. A changing marker gets one retry.
|
|
266
425
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
426
|
+
await validateDirectory();
|
|
267
427
|
const checkpointBefore = await leaseOwner(checkpointFile);
|
|
268
428
|
try {
|
|
269
429
|
const [metaValue, headValue, catalogValue] = await Promise.all([
|
|
270
|
-
readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes),
|
|
271
|
-
readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes),
|
|
272
|
-
readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES),
|
|
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),
|
|
273
433
|
]);
|
|
274
434
|
const meta = decodeMeta(metaValue);
|
|
275
435
|
const head = decodeHead(headValue);
|
|
276
436
|
const storedCatalog = decodeSessionCatalog(catalogValue);
|
|
277
437
|
assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
|
|
278
|
-
const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
438
|
+
const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
|
|
439
|
+
await validateDirectory();
|
|
279
440
|
const checkpointAfter = await leaseOwner(checkpointFile);
|
|
280
441
|
if (!sameLease(checkpointBefore, checkpointAfter))
|
|
281
442
|
continue;
|
|
@@ -284,7 +445,8 @@ export class DurableSessionStore {
|
|
|
284
445
|
break;
|
|
285
446
|
if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
|
|
286
447
|
break;
|
|
287
|
-
const active = await this.#leaseIsActive(id) ||
|
|
448
|
+
const active = await this.#leaseIsActive(id, directoryAnchor) ||
|
|
449
|
+
checkpointAfter !== undefined;
|
|
288
450
|
return catalogEntry(storedCatalog, active);
|
|
289
451
|
}
|
|
290
452
|
catch {
|
|
@@ -294,13 +456,20 @@ export class DurableSessionStore {
|
|
|
294
456
|
// Missing, stale, or malformed summaries are rebuilt only while the
|
|
295
457
|
// session is idle. Selecting a session still performs this strict load.
|
|
296
458
|
const checkpoint = await leaseOwner(checkpointFile);
|
|
297
|
-
if (await this.#leaseIsActive(id) ||
|
|
459
|
+
if (await this.#leaseIsActive(id, directoryAnchor) ||
|
|
298
460
|
(checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
|
|
299
461
|
return undefined;
|
|
300
|
-
const
|
|
301
|
-
|
|
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
|
+
}
|
|
302
471
|
const currentCheckpoint = await leaseOwner(checkpointFile);
|
|
303
|
-
const active = await this.#leaseIsActive(id) ||
|
|
472
|
+
const active = await this.#leaseIsActive(id, directoryAnchor) ||
|
|
304
473
|
(currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
|
|
305
474
|
return catalogEntry(snapshot.catalog, active);
|
|
306
475
|
}
|
|
@@ -312,40 +481,37 @@ export class DurableSessionStore {
|
|
|
312
481
|
}
|
|
313
482
|
async #writeCatalog(id, catalog, checkpointToken) {
|
|
314
483
|
const directory = this.#sessionDirectory(id);
|
|
484
|
+
const directoryAnchor = await this.#sessionAnchor(id);
|
|
485
|
+
const validateDirectory = async () => {
|
|
486
|
+
await this.#assertSessionAnchor(directoryAnchor);
|
|
487
|
+
};
|
|
315
488
|
const validate = async () => {
|
|
316
|
-
await
|
|
317
|
-
const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
489
|
+
await validateDirectory();
|
|
490
|
+
const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
|
|
318
491
|
if (!sameSessionHead(currentHead, catalog.head)) {
|
|
319
492
|
throw new Error("session head changed while updating its catalogue");
|
|
320
493
|
}
|
|
321
494
|
};
|
|
322
495
|
await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
|
|
323
496
|
if (checkpointToken !== undefined) {
|
|
497
|
+
await validateDirectory();
|
|
324
498
|
await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
|
|
499
|
+
await validateDirectory();
|
|
325
500
|
}
|
|
326
501
|
}
|
|
327
502
|
}
|
|
328
503
|
async function catalogNames(directory) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
throw error;
|
|
336
|
-
}
|
|
337
|
-
const names = [];
|
|
338
|
-
let entries = 0;
|
|
339
|
-
const handle = await opendir(directory);
|
|
340
|
-
for await (const entry of handle) {
|
|
341
|
-
entries++;
|
|
342
|
-
if (entries > MAX_CATALOG_ENTRIES) {
|
|
343
|
-
throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
|
|
344
|
-
}
|
|
345
|
-
if (entry.isDirectory() && SESSION_NAME.test(entry.name))
|
|
346
|
-
names.push(entry.name);
|
|
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`);
|
|
347
510
|
}
|
|
348
|
-
return
|
|
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));
|
|
349
515
|
}
|
|
350
516
|
function catalogEntry(catalog, active) {
|
|
351
517
|
if (catalog.resumeNodeId === 0)
|
|
@@ -360,7 +526,7 @@ function catalogEntry(catalog, active) {
|
|
|
360
526
|
});
|
|
361
527
|
}
|
|
362
528
|
function sameLease(left, right) {
|
|
363
|
-
return left?.token === right?.token;
|
|
529
|
+
return left?.token === right?.token && left?.legacy === right?.legacy;
|
|
364
530
|
}
|
|
365
531
|
function assertSessionWorkspace(meta, id, workspaceRoot, workspaceDigest) {
|
|
366
532
|
if (meta.id !== id || meta.workspaceDigest !== workspaceDigest ||
|
|
@@ -396,39 +562,72 @@ function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
|
396
562
|
throw new Error("session snapshot does not match its verified catalogue");
|
|
397
563
|
}
|
|
398
564
|
}
|
|
399
|
-
async function assertMissingNode(file) {
|
|
565
|
+
async function assertMissingNode(file, validate) {
|
|
566
|
+
await validate?.();
|
|
400
567
|
try {
|
|
401
568
|
await lstat(file);
|
|
402
569
|
}
|
|
403
570
|
catch (error) {
|
|
404
|
-
if (error.code === "ENOENT")
|
|
571
|
+
if (error.code === "ENOENT") {
|
|
572
|
+
await validate?.();
|
|
405
573
|
return;
|
|
574
|
+
}
|
|
406
575
|
throw error;
|
|
407
576
|
}
|
|
577
|
+
await validate?.();
|
|
408
578
|
throw new Error("session has an incomplete node outside its verified snapshot");
|
|
409
579
|
}
|
|
410
|
-
async function readNodes(directory) {
|
|
411
|
-
await
|
|
412
|
-
|
|
413
|
-
const names =
|
|
414
|
-
|
|
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();
|
|
415
600
|
if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
|
|
416
601
|
throw new Error("session has an invalid conversation size");
|
|
417
602
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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) });
|
|
421
620
|
}
|
|
621
|
+
await validate();
|
|
422
622
|
const stored = [];
|
|
423
623
|
const sequences = new Set();
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
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));
|
|
432
631
|
if (entry.node.id !== id) {
|
|
433
632
|
throw new Error("session conversation node identity is invalid");
|
|
434
633
|
}
|
|
@@ -438,47 +637,72 @@ async function readNodes(directory) {
|
|
|
438
637
|
if (sequences.has(entry.sequence)) {
|
|
439
638
|
throw new Error("session conversation node identity is invalid");
|
|
440
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
|
+
}
|
|
441
648
|
sequences.add(entry.sequence);
|
|
442
649
|
stored.push(entry);
|
|
443
650
|
}
|
|
444
651
|
}
|
|
652
|
+
await validate();
|
|
445
653
|
return stored;
|
|
446
654
|
}
|
|
447
|
-
async function readJson(file, limit) {
|
|
448
|
-
const details = await lstat(file);
|
|
449
|
-
if (details.isSymbolicLink() || !details.isFile() || details.size > limit) {
|
|
450
|
-
throw new Error("session file is unsafe or too large");
|
|
451
|
-
}
|
|
655
|
+
async function readJson(file, limit, expected, validate) {
|
|
452
656
|
try {
|
|
453
|
-
return JSON.parse(await
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
657
|
+
return JSON.parse(await readBoundedText(file, limit, {
|
|
658
|
+
label: "session file",
|
|
659
|
+
expected,
|
|
660
|
+
validate,
|
|
661
|
+
}));
|
|
457
662
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
663
|
+
catch (error) {
|
|
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
|
+
}
|
|
670
|
+
throw error;
|
|
463
671
|
}
|
|
464
672
|
}
|
|
465
|
-
async function
|
|
466
|
-
|
|
467
|
-
await assertDirectory(directory);
|
|
468
|
-
if (process.platform !== "win32")
|
|
469
|
-
await chmod(directory, DIRECTORY_MODE);
|
|
470
|
-
}
|
|
471
|
-
async function removeTemporaryDirectory(directory, bucket) {
|
|
472
|
-
const relative = path.relative(bucket, directory);
|
|
673
|
+
async function removeTemporaryDirectory(directory, bucket, expected) {
|
|
674
|
+
const relative = path.relative(bucket.path, directory);
|
|
473
675
|
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
|
|
474
676
|
!path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
|
|
475
677
|
throw new Error("refusing to remove an unverified session directory");
|
|
476
|
-
await
|
|
678
|
+
await assertDirectoryAnchor(bucket);
|
|
679
|
+
let observed;
|
|
680
|
+
try {
|
|
681
|
+
observed = await captureDirectDirectory(directory, "temporary session directory");
|
|
682
|
+
}
|
|
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");
|
|
697
|
+
}
|
|
698
|
+
await assertDirectoryAnchor(bucket);
|
|
699
|
+
await assertDirectoryAnchor(moved);
|
|
700
|
+
await rm(quarantine, { recursive: true });
|
|
477
701
|
}
|
|
478
702
|
function nodeName(id) {
|
|
479
703
|
return `${String(id).padStart(6, "0")}.json`;
|
|
480
704
|
}
|
|
481
|
-
function
|
|
705
|
+
export function reserveSessionId(now = new Date().toISOString()) {
|
|
482
706
|
return `${now.replace(/[-:.]/g, "").replace("Z", "Z")}-${randomUUID()}`;
|
|
483
707
|
}
|
|
484
708
|
function digestWorkspace(workspaceRoot) {
|