@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.
Files changed (53) hide show
  1. package/README.md +8 -6
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch.js +37 -4
  5. package/dist/bounded-file.js +212 -0
  6. package/dist/commands.js +2 -0
  7. package/dist/config.js +2 -1
  8. package/dist/context/automatic.js +35 -0
  9. package/dist/context/compactor.js +32 -2
  10. package/dist/context/manual.js +20 -3
  11. package/dist/context/request-projection.js +130 -0
  12. package/dist/controller-request.js +33 -11
  13. package/dist/credential-commands.js +22 -6
  14. package/dist/credentials.js +56 -18
  15. package/dist/directory-anchor.js +91 -0
  16. package/dist/file-identity.js +12 -0
  17. package/dist/model-command.js +5 -4
  18. package/dist/openai-account-command.js +13 -2
  19. package/dist/process-lease.js +329 -0
  20. package/dist/provider-commands.js +11 -5
  21. package/dist/provider-errors.js +37 -4
  22. package/dist/provider-label.js +13 -0
  23. package/dist/providers/anthropic-stream.js +4 -1
  24. package/dist/providers/anthropic-wire.js +8 -3
  25. package/dist/providers/anthropic.js +39 -19
  26. package/dist/providers/catalog.js +4 -4
  27. package/dist/providers/failure.js +181 -0
  28. package/dist/providers/http.js +82 -23
  29. package/dist/providers/ollama-stream.js +5 -1
  30. package/dist/providers/ollama.js +31 -19
  31. package/dist/providers/openai-codex.js +67 -41
  32. package/dist/providers/openai-stream.js +26 -2
  33. package/dist/providers/openai.js +51 -24
  34. package/dist/providers/sse.js +52 -8
  35. package/dist/request-identity.js +32 -0
  36. package/dist/sessions/lease.js +132 -49
  37. package/dist/sessions/runtime.js +15 -8
  38. package/dist/sessions/store.js +366 -142
  39. package/dist/settings.js +62 -10
  40. package/dist/stable-directory.js +148 -0
  41. package/dist/store-lock.js +68 -84
  42. package/dist/tools/args.js +2 -2
  43. package/dist/tools/fs.js +124 -102
  44. package/dist/tools/search.js +65 -100
  45. package/dist/tools/text-boundary.js +7 -33
  46. package/dist/tui/app-workflows.js +32 -4
  47. package/dist/tui/components/footer.js +1 -1
  48. package/dist/tui/feedback.js +4 -0
  49. package/dist/tui/session-view.js +7 -2
  50. package/dist/tui/workspace.js +21 -7
  51. package/dist/user-store.js +23 -31
  52. package/package.json +3 -4
  53. package/dist/tools/ripgrep.js +0 -230
@@ -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 { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, } from "node:fs/promises";
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 NODE_READ_CONCURRENCY = 4;
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
- constructor(workspaceRoot, sessionsRoot) {
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.#sessionsRoot = sessionsRoot;
32
- this.#bucket = path.join(sessionsRoot, this.workspaceDigest);
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
- return new DurableSessionStore(canonical, path.resolve(sessionsRoot));
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
- const names = await catalogNames(this.#bucket);
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 assertDirectory(directory);
59
- const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
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
- let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
62
- const stored = await readNodes(path.join(directory, "nodes"));
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 () => assertDirectory(directory),
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 = sessionId(now);
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 makePrivateDirectory(temporary);
177
+ temporaryAnchor = await createPrivateDirectory(temporary, "temporary session directory", DIRECTORY_MODE);
135
178
  const nodes = path.join(temporary, "nodes");
136
- await makePrivateDirectory(nodes);
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), { mode: FILE_MODE });
142
- await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), { mode: FILE_MODE });
143
- await atomicWrite(path.join(temporary, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE });
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 atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
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.#bucket);
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
- const lease = sessionLease(id, path.join(target, "active"), token);
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 assertDirectory(directory);
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 assertDirectory(directory);
167
- await assertDirectory(nodesDirectory);
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 currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
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
- await atomicWrite(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken, { mode: FILE_MODE, validate: async () => assertDirectory(directory) });
206
- await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
207
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
208
- mode: FILE_MODE,
209
- validate: async () => assertDirectory(directory),
210
- });
211
- await this.#writeCatalog(previous.meta.id, catalog, checkpointToken).catch(() => undefined);
212
- // Once the canonical head is durable, a missing summary is detectable by
213
- // its head mismatch and can be rebuilt without retaining a live marker.
214
- await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
215
- return Object.freeze({ meta: previous.meta, head, conversation, catalog });
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 handle.writeFile(token, "utf8");
227
- await handle.sync();
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
- finally {
230
- await handle.close();
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 (error.code !== "EEXIST")
335
+ if (primaryFailure === undefined)
236
336
  throw error;
237
- const owner = await leaseOwner(file);
238
- if (owner !== undefined && pidIsAlive(owner.pid)) {
239
- throw new Error("session is already open in another Jecode process");
240
- }
241
- if (owner !== undefined) {
242
- await removeLease(file, owner.token);
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
- throw new Error("session could not be claimed");
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 makePrivateDirectory(this.#sessionsRoot);
253
- await makePrivateDirectory(this.#bucket);
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 assertDirectory(directory);
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) || checkpointAfter !== undefined;
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 snapshot = await this.load(id);
301
- await this.#writeCatalog(id, snapshot.catalog, checkpoint?.token).catch(() => 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
+ }
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 assertDirectory(directory);
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
- try {
330
- await assertDirectory(directory);
331
- }
332
- catch (error) {
333
- if (error.code === "ENOENT")
334
- return [];
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 names.sort((left, right) => right.localeCompare(left));
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 assertDirectory(directory);
412
- const entries = await readdir(directory, { withFileTypes: true });
413
- const names = entries.filter((entry) => entry.isFile() && NODE_NAME.test(entry.name))
414
- .map((entry) => entry.name).sort();
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
- if (entries.length > CONVERSATION_LIMITS.nodes + 64 ||
419
- entries.some((entry) => !entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name)))) {
420
- throw new Error("session node directory contains unsupported data");
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
- for (let start = 0; start < names.length; start += NODE_READ_CONCURRENCY) {
425
- const decoded = await Promise.all(names.slice(start, start + NODE_READ_CONCURRENCY)
426
- .map(async (name, offset) => {
427
- const id = Number(NODE_NAME.exec(name)?.[1]);
428
- if (id !== start + offset + 1) {
429
- throw new Error("session conversation nodes are not contiguous");
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 readFile(file, "utf8"));
454
- }
455
- catch {
456
- throw new Error("session file is not valid JSON");
657
+ return JSON.parse(await readBoundedText(file, limit, {
658
+ label: "session file",
659
+ expected,
660
+ validate,
661
+ }));
457
662
  }
458
- }
459
- async function assertDirectory(directory) {
460
- const details = await lstat(directory);
461
- if (details.isSymbolicLink() || !details.isDirectory()) {
462
- throw new Error("session path is not a direct directory");
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 makePrivateDirectory(directory) {
466
- await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
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 rm(directory, { recursive: true, force: true });
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 sessionId(now) {
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) {