@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.
Files changed (55) hide show
  1. package/README.md +11 -8
  2. package/assets/wordmark-steel.svg +3 -0
  3. package/dist/accounts.js +47 -10
  4. package/dist/atomic.js +24 -14
  5. package/dist/batch.js +49 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/commands.js +2 -0
  8. package/dist/config.js +8 -4
  9. package/dist/context/automatic.js +35 -0
  10. package/dist/context/compactor.js +32 -2
  11. package/dist/context/manual.js +20 -3
  12. package/dist/context/request-projection.js +130 -0
  13. package/dist/controller-request.js +33 -11
  14. package/dist/credential-commands.js +22 -6
  15. package/dist/credentials.js +56 -18
  16. package/dist/directory-anchor.js +91 -0
  17. package/dist/file-identity.js +12 -0
  18. package/dist/model-command.js +5 -4
  19. package/dist/openai-account-command.js +13 -2
  20. package/dist/process-lease.js +329 -0
  21. package/dist/provider-commands.js +53 -10
  22. package/dist/provider-errors.js +94 -3
  23. package/dist/provider-label.js +13 -0
  24. package/dist/providers/anthropic-stream.js +4 -1
  25. package/dist/providers/anthropic-wire.js +8 -3
  26. package/dist/providers/anthropic.js +39 -19
  27. package/dist/providers/catalog.js +4 -4
  28. package/dist/providers/failure.js +181 -0
  29. package/dist/providers/http.js +86 -57
  30. package/dist/providers/ollama-stream.js +5 -1
  31. package/dist/providers/ollama.js +31 -19
  32. package/dist/providers/openai-codex.js +67 -41
  33. package/dist/providers/openai-stream.js +69 -9
  34. package/dist/providers/openai.js +51 -24
  35. package/dist/providers/sse.js +89 -17
  36. package/dist/request-identity.js +32 -0
  37. package/dist/sessions/catalog.js +199 -0
  38. package/dist/sessions/lease.js +132 -49
  39. package/dist/sessions/runtime.js +15 -8
  40. package/dist/sessions/store.js +451 -183
  41. package/dist/settings.js +62 -10
  42. package/dist/stable-directory.js +148 -0
  43. package/dist/store-lock.js +68 -84
  44. package/dist/tools/args.js +2 -2
  45. package/dist/tools/fs.js +124 -102
  46. package/dist/tools/search.js +81 -107
  47. package/dist/tools/text-boundary.js +7 -33
  48. package/dist/tui/app-workflows.js +32 -4
  49. package/dist/tui/components/footer.js +1 -1
  50. package/dist/tui/feedback.js +4 -0
  51. package/dist/tui/session-view.js +7 -2
  52. package/dist/tui/workspace.js +21 -7
  53. package/dist/user-store.js +23 -31
  54. package/package.json +4 -4
  55. package/dist/tools/ripgrep.js +0 -230
@@ -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 { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, } 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";
11
- import { leadingText } from "../text-boundary.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";
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 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)
46
62
  .map(async (id) => {
47
- try {
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 assertDirectory(directory);
76
- const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
77
- if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
78
- workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
79
- throw new Error("session belongs to a different workspace");
80
- let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
81
- const stored = await readNodes(path.join(directory, "nodes"));
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 () => 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
+ },
119
140
  });
120
141
  }
121
142
  const nodes = stored.map((entry) => entry.node);
122
143
  const conversation = ConversationTree.restore(nodes, head.nodeId);
123
- return Object.freeze({ meta, head, conversation });
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 = sessionId(now);
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 makePrivateDirectory(temporary);
177
+ temporaryAnchor = await createPrivateDirectory(temporary, "temporary session directory", DIRECTORY_MODE);
152
178
  const nodes = path.join(temporary, "nodes");
153
- 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
+ };
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), { mode: FILE_MODE });
159
- await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), { 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() });
160
198
  if (token !== undefined) {
161
- await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
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.#bucket);
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
- 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));
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 assertDirectory(directory);
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 assertDirectory(directory);
183
- await assertDirectory(nodesDirectory);
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 currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
187
- if (!sameHead(currentHead, previous.head)) {
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
- await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
220
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
221
- mode: FILE_MODE,
222
- validate: async () => assertDirectory(directory),
223
- });
224
- return Object.freeze({ meta: previous.meta, head, conversation });
225
- }
226
- async claim(id) {
227
- assertSessionId(id);
228
- await assertDirectory(this.#sessionDirectory(id));
229
- const file = path.join(this.#sessionDirectory(id), "active");
230
- const token = leaseToken();
231
- for (let attempt = 0; attempt < 2; attempt++) {
232
- try {
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 handle.writeFile(token, "utf8");
236
- await handle.sync();
294
+ verified = decodeHead(await readJson(headFile, SESSION_FILE_LIMITS.metadataBytes, headExpectation));
237
295
  }
238
- finally {
239
- await handle.close();
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 (error.code !== "EEXIST")
335
+ if (primaryFailure === undefined)
245
336
  throw error;
246
- const owner = await leaseOwner(file);
247
- if (owner !== undefined && pidIsAlive(owner.pid)) {
248
- throw new Error("session is already open in another Jecode process");
249
- }
250
- if (owner !== undefined) {
251
- 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");
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
- throw new Error("session could not be claimed");
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 makePrivateDirectory(this.#sessionsRoot);
262
- await makePrivateDirectory(this.#bucket);
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
- async function catalogNames(directory) {
270
- try {
271
- await assertDirectory(directory);
272
- }
273
- catch (error) {
274
- if (error.code === "ENOENT")
275
- return [];
276
- throw error;
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
- const names = [];
279
- let entries = 0;
280
- const handle = await opendir(directory);
281
- for await (const entry of handle) {
282
- entries++;
283
- if (entries > MAX_CATALOG_ENTRIES) {
284
- throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
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
- return names.sort((left, right) => right.localeCompare(left));
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 sameHead(left, right) {
317
- return left.version === right.version &&
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 assertDirectory(directory);
337
- const entries = await readdir(directory, { withFileTypes: true });
338
- const names = entries.filter((entry) => entry.isFile() && NODE_NAME.test(entry.name))
339
- .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();
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
- if (entries.length > CONVERSATION_LIMITS.nodes + 64 ||
344
- entries.some((entry) => !entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name)))) {
345
- 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) });
346
620
  }
621
+ await validate();
347
622
  const stored = [];
348
623
  const sequences = new Set();
349
- for (let start = 0; start < names.length; start += NODE_READ_CONCURRENCY) {
350
- const decoded = await Promise.all(names.slice(start, start + NODE_READ_CONCURRENCY)
351
- .map(async (name, offset) => {
352
- const id = Number(NODE_NAME.exec(name)?.[1]);
353
- if (id !== start + offset + 1) {
354
- throw new Error("session conversation nodes are not contiguous");
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 assertDirectory(directory);
399
- return await readdir(directory, { withFileTypes: true });
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.code === "ENOENT")
403
- return [];
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 rm(directory, { recursive: true, force: true });
413
- }
414
- function selectedTurnCount(conversation) {
415
- let count = 0;
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
- return count;
422
- }
423
- function firstUserText(conversation) {
424
- for (const message of conversation.history) {
425
- if (message.role !== "user")
426
- continue;
427
- const text = message.content.find((block) => block.kind === "text")?.text
428
- .replace(/\s+/gu, " ").trim();
429
- if (text !== undefined && text !== "")
430
- return leadingText(text, 160);
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
- return "Untitled session";
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 sessionId(now) {
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) {