@giovannijecha/jecode 0.8.4 → 0.8.6

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 (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
@@ -3,117 +3,61 @@
3
3
  // Each node is its own atomically replaced file. The head is advanced only
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
- import { createHash, randomUUID } from "node:crypto";
7
- import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, } from "node:fs/promises";
6
+ import { randomUUID } from "node:crypto";
7
+ import { lstat, rename } from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
- import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
10
+ import { stableFileExpectation } from "../bounded-file.js";
11
+ import { assertDirectoryAnchor, captureDirectDirectory, createPrivateDirectory, } from "../directory-anchor.js";
12
+ import { sameFileIdentity } from "../file-identity.js";
11
13
  import { userDataPath } from "../user-data.js";
12
- import { advanceSessionCatalog, catalogMatches, decodeSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_BYTES, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
13
- 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";
15
- const DIRECTORY_MODE = 0o700;
16
- const FILE_MODE = 0o600;
17
- const MAX_CATALOG_ENTRIES = 4_096;
18
- const CATALOG_READ_CONCURRENCY = 8;
19
- const NODE_READ_CONCURRENCY = 4;
20
- const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
21
- const NODE_NAME = /^(\d{6})\.json$/;
22
- const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
14
+ import { assertSessionId, SessionBucket } from "./bucket.js";
15
+ import { SessionCatalogIO } from "./catalog-io.js";
16
+ import { advanceSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
17
+ import { decodeHead, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
18
+ import { claimLeaseDirectory, createLeaseDirectory, leaseFromGeneration, leaseOwner, leaseToken, pidIsAlive, removeLegacyLeaseExclusive, removeLease, sessionLease, sessionLeaseOwns, } from "./lease.js";
19
+ import { assertMissingNode, DIRECTORY_MODE, FILE_MODE, nodeName, readJson, removeTemporaryDirectory, } from "./files.js";
20
+ import { loadSession } from "./load.js";
21
+ import { assertSharedNodes, assertSnapshot } from "./snapshot.js";
23
22
  export class DurableSessionStore {
24
23
  workspaceRoot;
25
24
  workspaceDigest;
26
- #sessionsRoot;
27
25
  #bucket;
28
- constructor(workspaceRoot, sessionsRoot) {
29
- this.workspaceRoot = workspaceRoot;
30
- this.workspaceDigest = digestWorkspace(workspaceRoot);
31
- this.#sessionsRoot = sessionsRoot;
32
- this.#bucket = path.join(sessionsRoot, this.workspaceDigest);
26
+ #catalog;
27
+ #hooks;
28
+ #leaseScope = Object.freeze({});
29
+ constructor(bucket, hooks) {
30
+ this.workspaceRoot = bucket.workspaceRoot;
31
+ this.workspaceDigest = bucket.workspaceDigest;
32
+ this.#bucket = bucket;
33
+ this.#hooks = hooks;
34
+ this.#catalog = new SessionCatalogIO(bucket, {
35
+ claim: (id) => this.claim(id),
36
+ load: (id, lease) => this.load(id, lease),
37
+ });
33
38
  }
34
- static async open(workspaceRoot, sessionsRoot = userDataPath("sessions")) {
35
- const canonical = await realpath(path.resolve(workspaceRoot));
36
- return new DurableSessionStore(canonical, path.resolve(sessionsRoot));
39
+ static async open(workspaceRoot, sessionsRoot = userDataPath("sessions"), hooks = {}) {
40
+ return new DurableSessionStore(await SessionBucket.open(workspaceRoot, sessionsRoot), hooks);
37
41
  }
38
42
  async list(limit = 32) {
39
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
40
- throw new Error("session catalogue limit is invalid");
41
- }
42
- const names = await catalogNames(this.#bucket);
43
- const catalog = [];
44
- for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
45
- const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
46
- .map(async (id) => {
47
- return await this.#catalogEntry(id);
48
- }));
49
- catalog.push(...batch.filter((entry) => entry !== undefined));
50
- }
51
- return catalog
52
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
53
- .slice(0, limit);
43
+ return this.#catalog.list(limit);
54
44
  }
55
- async load(id) {
56
- assertSessionId(id);
57
- const directory = this.#sessionDirectory(id);
58
- await assertDirectory(directory);
59
- const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
60
- 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"));
63
- const ahead = stored.filter((entry) => entry.sequence > head.sequence);
64
- if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
65
- throw new Error("session has an ambiguous incomplete checkpoint");
66
- }
67
- const byId = new Map(stored.map((entry) => [entry.node.id, entry]));
68
- const headed = byId.get(head.nodeId);
69
- if (headed === undefined)
70
- throw new Error("session head is missing its conversation node");
71
- if (ahead.length === 0) {
72
- if (headed.sequence !== head.sequence || headed.node.revision !== head.revision ||
73
- headed.node.parentId !== head.parentId) {
74
- throw new Error("session head does not match its conversation node");
75
- }
76
- }
77
- else {
78
- const candidate = ahead[0];
79
- const replacesHead = candidate.node.id === head.nodeId &&
80
- candidate.node.parentId === head.parentId &&
81
- candidate.node.revision === head.revision + 1;
82
- const candidateParent = byId.get(candidate.node.parentId);
83
- const extendsTree = candidate.node.id === stored.length &&
84
- candidate.node.revision === 1 && candidateParent !== undefined &&
85
- candidateParent.sequence <= head.sequence;
86
- if (!replacesHead && !extendsTree) {
87
- throw new Error("session checkpoint cannot be recovered safely");
88
- }
89
- head = Object.freeze({
90
- version: SESSION_SCHEMA,
91
- sequence: candidate.sequence,
92
- nodeId: candidate.node.id,
93
- parentId: candidate.node.parentId,
94
- revision: candidate.node.revision,
95
- updatedAt: candidate.updatedAt,
96
- });
97
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
98
- mode: FILE_MODE,
99
- validate: async () => assertDirectory(directory),
100
- });
101
- }
102
- const nodes = stored.map((entry) => entry.node);
103
- const conversation = ConversationTree.restore(nodes, head.nodeId);
104
- const catalog = sessionCatalog(meta, head, conversation);
105
- return Object.freeze({ meta, head, conversation, catalog });
45
+ async load(id, recoveryLease) {
46
+ return loadSession(this.#bucket, id, this.#leaseScope, recoveryLease);
106
47
  }
107
- async publish(conversation, claim) {
48
+ async publish(conversation, claim, reservedId) {
108
49
  const active = conversation.activeNode;
109
50
  if (active === undefined)
110
51
  throw new Error("an empty conversation cannot be persisted");
111
- await this.#ensureBucket();
52
+ await this.#bucket.assert();
112
53
  const now = new Date().toISOString();
113
- const id = sessionId(now);
54
+ const id = reservedId ?? reserveSessionId(now);
55
+ assertSessionId(id);
114
56
  const token = claim === true ? leaseToken() : undefined;
115
- const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
116
- const target = this.#sessionDirectory(id);
57
+ let leaseGeneration;
58
+ let temporaryAnchor;
59
+ const temporary = path.join(this.#bucket.anchor.path, `.${id}.${randomUUID()}.tmp`);
60
+ const target = this.#bucket.directory(id);
117
61
  const meta = Object.freeze({
118
62
  version: SESSION_SCHEMA,
119
63
  id,
@@ -131,43 +75,76 @@ export class DurableSessionStore {
131
75
  });
132
76
  const catalog = sessionCatalog(meta, head, conversation);
133
77
  try {
134
- await makePrivateDirectory(temporary);
78
+ temporaryAnchor = await createPrivateDirectory(temporary, "temporary session directory", DIRECTORY_MODE);
135
79
  const nodes = path.join(temporary, "nodes");
136
- await makePrivateDirectory(nodes);
80
+ const nodesAnchor = await createPrivateDirectory(nodes, "temporary session node directory", DIRECTORY_MODE);
81
+ const validateTemporary = async () => {
82
+ await this.#bucket.assert();
83
+ await assertDirectoryAnchor(temporaryAnchor);
84
+ await assertDirectoryAnchor(nodesAnchor);
85
+ };
137
86
  for (let index = 0; index < conversation.nodes.length; index++) {
138
87
  const node = conversation.nodes[index];
139
- await atomicWrite(path.join(nodes, nodeName(node.id)), encodeNode(node, index + 1, now), { mode: FILE_MODE });
88
+ await atomicWrite(path.join(nodes, nodeName(node.id)), encodeNode(node, index + 1, now), { mode: FILE_MODE, validate: async () => validateTemporary() });
140
89
  }
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 });
90
+ await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), {
91
+ mode: FILE_MODE,
92
+ validate: async () => validateTemporary(),
93
+ });
94
+ await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), {
95
+ mode: FILE_MODE,
96
+ validate: async () => validateTemporary(),
97
+ });
98
+ await atomicWrite(path.join(temporary, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate: async () => validateTemporary() });
144
99
  if (token !== undefined) {
145
- await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
100
+ leaseGeneration = await createLeaseDirectory(path.join(temporary, "active"), token);
146
101
  }
102
+ await validateTemporary();
147
103
  await rename(temporary, target);
104
+ await this.#bucket.assert();
105
+ const targetAnchor = await captureDirectDirectory(target, "session directory");
106
+ if (!sameFileIdentity(temporaryAnchor.identity, targetAnchor.identity)) {
107
+ throw new Error("session directory changed while publishing");
108
+ }
148
109
  }
149
110
  catch (error) {
150
- await removeTemporaryDirectory(temporary, this.#bucket);
111
+ await removeTemporaryDirectory(temporary, this.#bucket.anchor, temporaryAnchor)
112
+ .catch(() => undefined);
151
113
  throw error;
152
114
  }
153
115
  const snapshot = Object.freeze({ meta, head, conversation, catalog });
154
116
  if (token === undefined)
155
117
  return snapshot;
156
- const lease = sessionLease(id, path.join(target, "active"), token);
118
+ if (leaseGeneration === undefined)
119
+ throw new Error("session lease was not initialized");
120
+ const lease = sessionLease(id, this.#leaseScope, leaseFromGeneration(path.join(target, "active"), leaseGeneration));
157
121
  return Object.freeze({ ...snapshot, lease });
158
122
  }
159
- async checkpoint(previous, conversation) {
123
+ async checkpoint(previous, conversation, lease) {
160
124
  assertSnapshot(previous, this.workspaceRoot, this.workspaceDigest);
161
125
  const id = previous.meta.id;
162
- const directory = this.#sessionDirectory(id);
163
- await assertDirectory(directory);
126
+ if (!sessionLeaseOwns(lease, id, this.#leaseScope)) {
127
+ throw new Error("session checkpoint requires exclusive ownership");
128
+ }
129
+ await lease.assertOwned();
130
+ const directory = this.#bucket.directory(id);
131
+ const directoryAnchor = await this.#bucket.captureSession(id);
164
132
  const nodesDirectory = path.join(directory, "nodes");
133
+ const nodesAnchor = await captureDirectDirectory(nodesDirectory, "session node directory");
165
134
  const validateNodesDirectory = async () => {
166
- await assertDirectory(directory);
167
- await assertDirectory(nodesDirectory);
135
+ await Promise.all([
136
+ lease.assertOwned(),
137
+ this.#bucket.assert(),
138
+ assertDirectoryAnchor(directoryAnchor),
139
+ assertDirectoryAnchor(nodesAnchor),
140
+ ]);
168
141
  };
169
142
  await validateNodesDirectory();
170
- const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
143
+ const headFile = path.join(directory, "head.json");
144
+ const headExpectation = stableFileExpectation(await lstat(headFile, { bigint: true }));
145
+ await validateNodesDirectory();
146
+ const currentHead = decodeHead(await readJson(headFile, SESSION_FILE_LIMITS.metadataBytes, headExpectation));
147
+ await validateNodesDirectory();
171
148
  if (!sameSessionHead(currentHead, previous.head)) {
172
149
  throw new Error("session head changed after its verified snapshot");
173
150
  }
@@ -202,293 +179,121 @@ export class DurableSessionStore {
202
179
  });
203
180
  const catalog = advanceSessionCatalog(previous.catalog, previous.meta, head, conversation);
204
181
  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);
182
+ const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
183
+ await this.#hooks.beforeCheckpointLease?.();
184
+ await validateNodesDirectory();
185
+ const checkpointLease = await claimLeaseDirectory(checkpointFile, checkpointToken);
186
+ if (checkpointLease === undefined)
187
+ throw new Error("session checkpoint is already active");
188
+ let primaryFailure;
189
+ try {
190
+ await this.#hooks.afterCheckpointLease?.();
191
+ const assertBaseHead = async () => {
192
+ await Promise.all([checkpointLease.assertOwned(), validateNodesDirectory()]);
193
+ let verified;
225
194
  try {
226
- await handle.writeFile(token, "utf8");
227
- await handle.sync();
195
+ verified = decodeHead(await readJson(headFile, SESSION_FILE_LIMITS.metadataBytes, headExpectation));
228
196
  }
229
- finally {
230
- await handle.close();
197
+ catch (error) {
198
+ throw new Error("session head changed after its verified snapshot", { cause: error });
231
199
  }
232
- return sessionLease(id, file, token);
233
- }
234
- catch (error) {
235
- if (error.code !== "EEXIST")
236
- 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);
200
+ await Promise.all([checkpointLease.assertOwned(), validateNodesDirectory()]);
201
+ if (!sameSessionHead(verified, previous.head)) {
202
+ throw new Error("session head changed after its verified snapshot");
243
203
  }
204
+ };
205
+ await assertBaseHead();
206
+ if (extendsTree) {
207
+ await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)), validateNodesDirectory);
244
208
  }
209
+ // Publish the next head's index first. Until the head commits, listing
210
+ // treats it as suspect and loads the tree after ownership is released.
211
+ // A failed node/head write must never leave an apparently current index.
212
+ await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), {
213
+ mode: FILE_MODE,
214
+ validate: async () => assertBaseHead(),
215
+ });
216
+ await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), {
217
+ mode: FILE_MODE,
218
+ validate: async (phase) => {
219
+ await assertBaseHead();
220
+ if (extendsTree && phase === "before-rename") {
221
+ await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)), validateNodesDirectory);
222
+ }
223
+ },
224
+ });
225
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
226
+ mode: FILE_MODE,
227
+ validate: async () => assertBaseHead(),
228
+ });
229
+ return Object.freeze({ meta: previous.meta, head, conversation, catalog });
245
230
  }
246
- throw new Error("session could not be claimed");
247
- }
248
- #sessionDirectory(id) {
249
- return path.join(this.#bucket, id);
250
- }
251
- async #ensureBucket() {
252
- await makePrivateDirectory(this.#sessionsRoot);
253
- await makePrivateDirectory(this.#bucket);
254
- }
255
- async #leaseIsActive(id) {
256
- const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
257
- return owner !== undefined && pidIsAlive(owner.pid);
258
- }
259
- async #catalogEntry(id) {
260
- try {
261
- const directory = this.#sessionDirectory(id);
262
- await assertDirectory(directory);
263
- const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
264
- // A second head read closes the only useful race: a checkpoint landing
265
- // between the small record reads. A changing marker gets one retry.
266
- for (let attempt = 0; attempt < 2; attempt++) {
267
- const checkpointBefore = await leaseOwner(checkpointFile);
268
- try {
269
- 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),
273
- ]);
274
- const meta = decodeMeta(metaValue);
275
- const head = decodeHead(headValue);
276
- const storedCatalog = decodeSessionCatalog(catalogValue);
277
- assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
278
- const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
279
- const checkpointAfter = await leaseOwner(checkpointFile);
280
- if (!sameLease(checkpointBefore, checkpointAfter))
281
- continue;
282
- if (!sameSessionHead(head, confirmedHead) ||
283
- !catalogMatches(storedCatalog, meta, head))
284
- break;
285
- if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
286
- break;
287
- const active = await this.#leaseIsActive(id) || checkpointAfter !== undefined;
288
- return catalogEntry(storedCatalog, active);
289
- }
290
- catch {
291
- break;
231
+ catch (error) {
232
+ primaryFailure = error;
233
+ throw error;
234
+ }
235
+ finally {
236
+ try {
237
+ if (!(await checkpointLease.release()) && primaryFailure === undefined) {
238
+ throw new Error("session checkpoint ownership was lost");
292
239
  }
293
240
  }
294
- // Missing, stale, or malformed summaries are rebuilt only while the
295
- // session is idle. Selecting a session still performs this strict load.
296
- const checkpoint = await leaseOwner(checkpointFile);
297
- if (await this.#leaseIsActive(id) ||
298
- (checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
299
- return undefined;
300
- const snapshot = await this.load(id);
301
- await this.#writeCatalog(id, snapshot.catalog, checkpoint?.token).catch(() => undefined);
302
- const currentCheckpoint = await leaseOwner(checkpointFile);
303
- const active = await this.#leaseIsActive(id) ||
304
- (currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
305
- return catalogEntry(snapshot.catalog, active);
306
- }
307
- catch {
308
- // Corrupt, unsafe, active-without-a-summary, or foreign data never
309
- // becomes a resume candidate.
310
- return undefined;
311
- }
312
- }
313
- async #writeCatalog(id, catalog, checkpointToken) {
314
- const directory = this.#sessionDirectory(id);
315
- const validate = async () => {
316
- await assertDirectory(directory);
317
- const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
318
- if (!sameSessionHead(currentHead, catalog.head)) {
319
- throw new Error("session head changed while updating its catalogue");
241
+ catch (error) {
242
+ if (primaryFailure === undefined)
243
+ throw error;
320
244
  }
321
- };
322
- await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
323
- if (checkpointToken !== undefined) {
324
- await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
325
245
  }
326
246
  }
327
- }
328
- 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`);
247
+ async claim(id) {
248
+ assertSessionId(id);
249
+ const directory = this.#bucket.directory(id);
250
+ const directoryAnchor = await this.#bucket.captureSession(id);
251
+ const validateDirectory = async () => {
252
+ await this.#bucket.assertSession(directoryAnchor);
253
+ };
254
+ await validateDirectory();
255
+ const file = path.join(directory, "active");
256
+ const previous = await leaseOwner(file);
257
+ await validateDirectory();
258
+ if (previous?.legacy === true) {
259
+ if (pidIsAlive(previous.pid)) {
260
+ throw new Error("session is already open in an older Jecode process");
261
+ }
262
+ throw new Error("session has a stale legacy active marker; close older Jecode processes and remove it before retrying");
344
263
  }
345
- if (entry.isDirectory() && SESSION_NAME.test(entry.name))
346
- names.push(entry.name);
347
- }
348
- return names.sort((left, right) => right.localeCompare(left));
349
- }
350
- function catalogEntry(catalog, active) {
351
- if (catalog.resumeNodeId === 0)
352
- return undefined;
353
- return Object.freeze({
354
- id: catalog.id,
355
- createdAt: catalog.createdAt,
356
- updatedAt: catalog.head.updatedAt,
357
- turns: catalog.turns,
358
- preview: catalog.preview,
359
- active,
360
- });
361
- }
362
- function sameLease(left, right) {
363
- return left?.token === right?.token;
364
- }
365
- function assertSessionWorkspace(meta, id, workspaceRoot, workspaceDigest) {
366
- if (meta.id !== id || meta.workspaceDigest !== workspaceDigest ||
367
- workspaceKey(meta.workspaceRoot) !== workspaceKey(workspaceRoot))
368
- throw new Error("session belongs to a different workspace");
369
- }
370
- function assertSharedNodes(previous, next, replacedId) {
371
- for (const node of previous.nodes) {
372
- if (node.id === replacedId)
373
- continue;
374
- const candidate = next.node(node.id);
375
- if (candidate !== node) {
376
- throw new Error("session checkpoint rewrites prior conversation history");
264
+ const token = leaseToken();
265
+ const lease = await claimLeaseDirectory(file, token);
266
+ if (lease === undefined) {
267
+ throw new Error("session is already open in another Jecode process");
377
268
  }
378
- }
379
- }
380
- function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
381
- encodeMeta(snapshot.meta);
382
- encodeHead(snapshot.head);
383
- encodeSessionCatalog(snapshot.catalog);
384
- assertSessionId(snapshot.meta.id);
385
- if (snapshot.meta.workspaceDigest !== workspaceDigest ||
386
- workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
387
- throw new Error("session snapshot belongs to a different workspace");
388
- const active = snapshot.conversation.activeNode;
389
- if (active === undefined ||
390
- active.id !== snapshot.head.nodeId ||
391
- active.parentId !== snapshot.head.parentId ||
392
- active.revision !== snapshot.head.revision ||
393
- snapshot.head.sequence < snapshot.conversation.nodes.length)
394
- throw new Error("session snapshot does not match its verified head");
395
- if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
396
- throw new Error("session snapshot does not match its verified catalogue");
397
- }
398
- }
399
- async function assertMissingNode(file) {
400
- try {
401
- await lstat(file);
402
- }
403
- catch (error) {
404
- if (error.code === "ENOENT")
405
- return;
406
- throw error;
407
- }
408
- throw new Error("session has an incomplete node outside its verified snapshot");
409
- }
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();
415
- if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
416
- throw new Error("session has an invalid conversation size");
417
- }
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");
421
- }
422
- const stored = [];
423
- 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");
269
+ const owned = sessionLease(id, this.#leaseScope, lease);
270
+ const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
271
+ try {
272
+ await owned.assertOwned();
273
+ await validateDirectory();
274
+ const checkpoint = await leaseOwner(checkpointFile);
275
+ if (checkpoint !== undefined && pidIsAlive(checkpoint.pid)) {
276
+ throw new Error("session has a live checkpoint from another Jecode process");
430
277
  }
431
- const entry = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
432
- if (entry.node.id !== id) {
433
- throw new Error("session conversation node identity is invalid");
278
+ if (checkpoint?.legacy === true) {
279
+ if (!await removeLegacyLeaseExclusive(checkpointFile, checkpoint.token, owned)) {
280
+ throw new Error("session legacy checkpoint changed during migration");
281
+ }
434
282
  }
435
- return entry;
436
- }));
437
- for (const entry of decoded) {
438
- if (sequences.has(entry.sequence)) {
439
- throw new Error("session conversation node identity is invalid");
283
+ else if (checkpoint !== undefined &&
284
+ !await removeLease(checkpointFile, checkpoint.token)) {
285
+ throw new Error("session checkpoint changed during recovery");
440
286
  }
441
- sequences.add(entry.sequence);
442
- stored.push(entry);
287
+ await owned.assertOwned();
288
+ await validateDirectory();
443
289
  }
444
- }
445
- return stored;
446
- }
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
- }
452
- try {
453
- return JSON.parse(await readFile(file, "utf8"));
454
- }
455
- catch {
456
- throw new Error("session file is not valid JSON");
457
- }
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");
290
+ catch (error) {
291
+ await owned.close().catch(() => undefined);
292
+ throw error;
293
+ }
294
+ return owned;
463
295
  }
464
296
  }
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);
473
- if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
474
- !path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
475
- throw new Error("refusing to remove an unverified session directory");
476
- await rm(directory, { recursive: true, force: true });
477
- }
478
- function nodeName(id) {
479
- return `${String(id).padStart(6, "0")}.json`;
480
- }
481
- function sessionId(now) {
297
+ export function reserveSessionId(now = new Date().toISOString()) {
482
298
  return `${now.replace(/[-:.]/g, "").replace("Z", "Z")}-${randomUUID()}`;
483
299
  }
484
- function digestWorkspace(workspaceRoot) {
485
- return createHash("sha256").update(workspaceKey(workspaceRoot)).digest("hex");
486
- }
487
- function workspaceKey(workspaceRoot) {
488
- const normalized = path.normalize(workspaceRoot);
489
- return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
490
- }
491
- function assertSessionId(id) {
492
- if (!SESSION_NAME.test(id))
493
- throw new Error("session id is invalid");
494
- }