@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
@@ -1,19 +1,12 @@
1
1
  // Strict codecs for session files. Disk is an untrusted boundary even when
2
2
  // the directory is owner-only: every value is bounded and re-owned before it
3
3
  // can become conversation or provider input.
4
- import { Buffer } from "node:buffer";
5
4
  import { CONTEXT_LIMITS } from "../context/projection.js";
6
- import { MAX_TEXT_CODE_UNITS } from "../text-boundary.js";
5
+ import { messageFromRecord, messageRecord } from "./codec-messages.js";
6
+ import { blockFromRecord, blockRecord } from "./codec-transcript.js";
7
+ import { bounded, boundedLine, digest, identifier, integer, invalid, keys, record, SESSION_FILE_LIMITS, timestamp, } from "./codec-values.js";
8
+ export { SESSION_FILE_LIMITS } from "./codec-values.js";
7
9
  export const SESSION_SCHEMA = 4;
8
- export const SESSION_FILE_LIMITS = Object.freeze({
9
- text: MAX_TEXT_CODE_UNITS,
10
- metadataBytes: 64 * 1_024,
11
- nodeBytes: 20 * 1_024 * 1_024,
12
- jsonDepth: 24,
13
- jsonNodes: 32_768,
14
- blocks: 8_192,
15
- details: 8_192,
16
- });
17
10
  export function encodeMeta(meta) {
18
11
  decodeMeta(meta);
19
12
  return boundedLine(meta, SESSION_FILE_LIMITS.metadataBytes);
@@ -176,236 +169,6 @@ function contextFromRecord(value, ownerId, ownerMessages) {
176
169
  summary: value["summary"],
177
170
  };
178
171
  }
179
- function messageRecord(message) {
180
- return {
181
- role: message.role,
182
- content: message.content.map(contentRecord),
183
- usage: message.usage ?? null,
184
- };
185
- }
186
- function messageFromRecord(value) {
187
- if (!record(value) || !keys(value, "content,role,usage"))
188
- throw invalid();
189
- if ((value["role"] !== "user" && value["role"] !== "assistant") ||
190
- !Array.isArray(value["content"]) || value["content"].length > SESSION_FILE_LIMITS.blocks)
191
- throw invalid();
192
- const usage = value["usage"] === null ? undefined : usageFromRecord(value["usage"]);
193
- return {
194
- role: value["role"],
195
- content: value["content"].map(contentFromRecord),
196
- ...(usage === undefined ? {} : { usage }),
197
- };
198
- }
199
- function contentRecord(block) {
200
- if (block.kind === "text")
201
- return { kind: block.kind, text: block.text };
202
- if (block.kind === "tool_call") {
203
- return { kind: block.kind, id: block.id, name: block.name, input: block.input };
204
- }
205
- return {
206
- kind: block.kind,
207
- id: block.id,
208
- output: block.output,
209
- isError: block.isError,
210
- };
211
- }
212
- function contentFromRecord(value) {
213
- if (!record(value) || typeof value["kind"] !== "string")
214
- throw invalid();
215
- if (value["kind"] === "text" && keys(value, "kind,text") && boundedText(value["text"])) {
216
- return { kind: "text", text: value["text"] };
217
- }
218
- if (value["kind"] === "tool_call" && keys(value, "id,input,kind,name") &&
219
- bounded(value["id"], 512) && bounded(value["name"], 256)) {
220
- const input = jsonObject(value["input"]);
221
- return { kind: "tool_call", id: value["id"], name: value["name"], input };
222
- }
223
- if (value["kind"] === "tool_result" && keys(value, "id,isError,kind,output") &&
224
- bounded(value["id"], 512) && boundedText(value["output"]) &&
225
- typeof value["isError"] === "boolean") {
226
- return {
227
- kind: "tool_result",
228
- id: value["id"],
229
- output: value["output"],
230
- isError: value["isError"],
231
- };
232
- }
233
- throw invalid();
234
- }
235
- function usageFromRecord(value) {
236
- if (!record(value) || !keys(value, "cacheWriteInputTokens,cachedInputTokens,inputTokens,outputTokens,reasoningTokens"))
237
- throw invalid();
238
- const inputTokens = value["inputTokens"];
239
- const outputTokens = value["outputTokens"];
240
- const cachedInputTokens = value["cachedInputTokens"];
241
- const cacheWriteInputTokens = value["cacheWriteInputTokens"];
242
- const reasoningTokens = value["reasoningTokens"];
243
- if (!integer(inputTokens, 0) || !integer(outputTokens, 0) ||
244
- !integer(cachedInputTokens, 0) || !integer(cacheWriteInputTokens, 0) ||
245
- !integer(reasoningTokens, 0))
246
- throw invalid();
247
- return {
248
- inputTokens,
249
- outputTokens,
250
- cachedInputTokens,
251
- cacheWriteInputTokens,
252
- reasoningTokens,
253
- };
254
- }
255
- function blockRecord(block) {
256
- if (block.kind === "notice")
257
- return [];
258
- if (block.kind === "user" || block.kind === "answer" || block.kind === "reasoning") {
259
- return [{ kind: block.kind, text: block.text }];
260
- }
261
- if (block.tone === "pending")
262
- return [];
263
- return [{
264
- kind: block.kind,
265
- name: block.name,
266
- target: block.target,
267
- right: block.right,
268
- tone: block.tone,
269
- body: block.body?.map(detailRecord) ?? null,
270
- durationMs: block.durationMs ?? null,
271
- }];
272
- }
273
- function blockFromRecord(value, version) {
274
- if (!record(value) || typeof value["kind"] !== "string")
275
- throw invalid();
276
- if ((value["kind"] === "user" || value["kind"] === "answer" || value["kind"] === "reasoning") &&
277
- keys(value, "kind,text") && boundedText(value["text"]))
278
- return { kind: value["kind"], text: value["text"] };
279
- const toolKeys = version >= 4
280
- ? "body,durationMs,kind,name,right,target,tone"
281
- : "body,kind,name,right,target,tone";
282
- if (value["kind"] === "tool" && keys(value, toolKeys) &&
283
- bounded(value["name"], 256) && boundedText(value["target"]) &&
284
- boundedText(value["right"], 1_024) &&
285
- (value["tone"] === "ok" || value["tone"] === "fail" || value["tone"] === "deny") &&
286
- (version < 4 || nullableInteger(value["durationMs"], 0)) &&
287
- (value["body"] === null ||
288
- (Array.isArray(value["body"]) && value["body"].length <= SESSION_FILE_LIMITS.details))) {
289
- const body = value["body"] === null
290
- ? undefined
291
- : value["body"].map(detailFromRecord);
292
- return {
293
- kind: "tool",
294
- name: value["name"],
295
- target: value["target"],
296
- right: value["right"],
297
- tone: value["tone"],
298
- ...(body === undefined ? {} : { body }),
299
- ...(version < 4 || value["durationMs"] === null
300
- ? {}
301
- : { durationMs: value["durationMs"] }),
302
- };
303
- }
304
- throw invalid();
305
- }
306
- function detailRecord(detail) {
307
- if (detail.kind === "out" || detail.kind === "gap")
308
- return { kind: detail.kind, text: detail.text };
309
- return {
310
- kind: detail.kind,
311
- text: detail.text,
312
- oldLine: detail.oldLine ?? null,
313
- newLine: detail.newLine ?? null,
314
- emphasis: detail.emphasis ?? null,
315
- };
316
- }
317
- function detailFromRecord(value) {
318
- if (!record(value) || typeof value["kind"] !== "string")
319
- throw invalid();
320
- if ((value["kind"] === "out" || value["kind"] === "gap") &&
321
- keys(value, "kind,text") && boundedText(value["text"]))
322
- return { kind: value["kind"], text: value["text"] };
323
- if ((value["kind"] === "keep" || value["kind"] === "add" || value["kind"] === "del") &&
324
- keys(value, "emphasis,kind,newLine,oldLine,text") && boundedText(value["text"]) &&
325
- nullableInteger(value["oldLine"], 1) && nullableInteger(value["newLine"], 1)) {
326
- const emphasis = emphasisFromRecord(value["emphasis"]);
327
- return {
328
- kind: value["kind"],
329
- text: value["text"],
330
- ...(value["oldLine"] === null ? {} : { oldLine: value["oldLine"] }),
331
- ...(value["newLine"] === null ? {} : { newLine: value["newLine"] }),
332
- ...(emphasis === undefined ? {} : { emphasis }),
333
- };
334
- }
335
- throw invalid();
336
- }
337
- function emphasisFromRecord(value) {
338
- if (value === null)
339
- return undefined;
340
- if (!record(value) || !keys(value, "length,start"))
341
- throw invalid();
342
- if (!integer(value["start"], 0) || !integer(value["length"], 1))
343
- throw invalid();
344
- return { start: value["start"], length: value["length"] };
345
- }
346
- function jsonObject(value) {
347
- const budget = { nodes: 0 };
348
- const safe = jsonValue(value, budget, 0);
349
- if (!record(safe))
350
- throw invalid();
351
- return safe;
352
- }
353
- function jsonValue(value, budget, depth) {
354
- budget.nodes++;
355
- if (budget.nodes > SESSION_FILE_LIMITS.jsonNodes || depth > SESSION_FILE_LIMITS.jsonDepth) {
356
- throw invalid();
357
- }
358
- if (value === null || typeof value === "boolean")
359
- return value;
360
- if (typeof value === "string") {
361
- if (!boundedText(value))
362
- throw invalid();
363
- return value;
364
- }
365
- if (typeof value === "number") {
366
- if (!Number.isFinite(value))
367
- throw invalid();
368
- return value;
369
- }
370
- if (Array.isArray(value)) {
371
- if (value.length > SESSION_FILE_LIMITS.blocks)
372
- throw invalid();
373
- return value.map((item) => jsonValue(item, budget, depth + 1));
374
- }
375
- if (!record(value) || Object.keys(value).length > SESSION_FILE_LIMITS.blocks)
376
- throw invalid();
377
- const safe = {};
378
- for (const [name, child] of Object.entries(value)) {
379
- if (!boundedText(name, 1_024))
380
- throw invalid();
381
- safe[name] = jsonValue(child, budget, depth + 1);
382
- }
383
- return safe;
384
- }
385
- function line(value) {
386
- return `${JSON.stringify(value, null, 2)}\n`;
387
- }
388
- function boundedLine(value, maxBytes) {
389
- const encoded = line(value);
390
- if (Buffer.byteLength(encoded, "utf8") > maxBytes)
391
- throw invalid();
392
- return encoded;
393
- }
394
- function keys(value, expected) {
395
- return Object.keys(value).sort().join(",") === expected;
396
- }
397
- function record(value) {
398
- return typeof value === "object" && value !== null && !Array.isArray(value);
399
- }
400
- function bounded(value, limit = SESSION_FILE_LIMITS.text) {
401
- return typeof value === "string" && value.length > 0 && value.length <= limit;
402
- }
403
- function boundedText(value, limit = SESSION_FILE_LIMITS.text) {
404
- return typeof value === "string" && value.length <= limit;
405
- }
406
- function integer(value, minimum) {
407
- return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
408
- }
409
172
  function schema(value) {
410
173
  return value === 1 || value === 2 || value === 3 || value === SESSION_SCHEMA;
411
174
  }
@@ -413,19 +176,3 @@ function settlement(value, version) {
413
176
  return value === "checkpointed" || value === "completed" ||
414
177
  (version >= 3 && (value === "failed" || value === "interrupted"));
415
178
  }
416
- function nullableInteger(value, minimum) {
417
- return value === null || integer(value, minimum);
418
- }
419
- function identifier(value) {
420
- return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
421
- }
422
- function digest(value) {
423
- return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
424
- }
425
- function timestamp(value) {
426
- return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
427
- Number.isFinite(Date.parse(value));
428
- }
429
- function invalid() {
430
- return new Error("session data is invalid or unsupported");
431
- }
@@ -0,0 +1,158 @@
1
+ // Bounded session-file IO and cleanup of verified temporary directories.
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, opendir, rename, rm } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { BoundedFileError, readBoundedText, stableFileExpectation } from "../bounded-file.js";
6
+ import { CONVERSATION_LIMITS } from "../conversation.js";
7
+ import { assertDirectoryAnchor, captureDirectDirectory } from "../directory-anchor.js";
8
+ import { sameFileIdentity } from "../file-identity.js";
9
+ import { decodeNode, SESSION_FILE_LIMITS } from "./codec.js";
10
+ export const DIRECTORY_MODE = 0o700;
11
+ export const FILE_MODE = 0o600;
12
+ export const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
13
+ const MAX_NODE_READ_CONCURRENCY = 8;
14
+ const MAX_NODE_READ_IN_FLIGHT_BYTES = 64 * 1_024 * 1_024;
15
+ const MAX_SESSION_NODE_BYTES = 192 * 1_024 * 1_024;
16
+ 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)));
17
+ const NODE_NAME = /^(\d{6})\.json$/;
18
+ const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
19
+ export async function assertMissingNode(file, validate) {
20
+ await validate?.();
21
+ try {
22
+ await lstat(file);
23
+ }
24
+ catch (error) {
25
+ if (error.code === "ENOENT") {
26
+ await validate?.();
27
+ return;
28
+ }
29
+ throw error;
30
+ }
31
+ await validate?.();
32
+ throw new Error("session has an incomplete node outside its verified snapshot");
33
+ }
34
+ export async function readNodes(directory, validate) {
35
+ await validate();
36
+ await assertDirectoryAnchor(directory);
37
+ const names = [];
38
+ let entries = 0;
39
+ const handle = await opendir(directory.path);
40
+ for await (const entry of handle) {
41
+ entries++;
42
+ if (entries > CONVERSATION_LIMITS.nodes + 64) {
43
+ throw new Error("session node directory contains unsupported data");
44
+ }
45
+ if (!entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name))) {
46
+ throw new Error("session node directory contains unsupported data");
47
+ }
48
+ if (NODE_NAME.test(entry.name))
49
+ names.push(entry.name);
50
+ }
51
+ await validate();
52
+ await assertDirectoryAnchor(directory);
53
+ names.sort();
54
+ if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
55
+ throw new Error("session has an invalid conversation size");
56
+ }
57
+ const files = [];
58
+ let storedBytes = 0;
59
+ for (let index = 0; index < names.length; index++) {
60
+ const name = names[index];
61
+ const id = Number(NODE_NAME.exec(name)?.[1]);
62
+ if (id !== index + 1) {
63
+ throw new Error("session conversation nodes are not contiguous");
64
+ }
65
+ const details = await lstat(path.join(directory.path, name), { bigint: true });
66
+ if (details.isSymbolicLink() || !details.isFile() || details.size < 0n ||
67
+ details.size > BigInt(SESSION_FILE_LIMITS.nodeBytes))
68
+ throw new Error("session node file is unsafe or too large");
69
+ storedBytes += Number(details.size);
70
+ if (storedBytes > MAX_SESSION_NODE_BYTES) {
71
+ throw new Error("session node files exceed their aggregate storage limit");
72
+ }
73
+ files.push({ name, id, expected: stableFileExpectation(details) });
74
+ }
75
+ await validate();
76
+ const stored = [];
77
+ const sequences = new Set();
78
+ let messageCodeUnits = 0;
79
+ let transcriptCodeUnits = 0;
80
+ let contextCodeUnits = 0;
81
+ for (let start = 0; start < files.length; start += NODE_READ_CONCURRENCY) {
82
+ const decoded = await Promise.all(files.slice(start, start + NODE_READ_CONCURRENCY)
83
+ .map(async ({ name, id, expected }) => {
84
+ const entry = decodeNode(await readJson(path.join(directory.path, name), SESSION_FILE_LIMITS.nodeBytes, expected));
85
+ if (entry.node.id !== id) {
86
+ throw new Error("session conversation node identity is invalid");
87
+ }
88
+ return entry;
89
+ }));
90
+ for (const entry of decoded) {
91
+ if (sequences.has(entry.sequence)) {
92
+ throw new Error("session conversation node identity is invalid");
93
+ }
94
+ messageCodeUnits += JSON.stringify(entry.node.messages).length;
95
+ transcriptCodeUnits += JSON.stringify(entry.node.blocks).length;
96
+ contextCodeUnits += entry.node.context?.summary.length ?? 0;
97
+ if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits ||
98
+ transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits ||
99
+ contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
100
+ throw new Error("session conversation exceeds its aggregate limit");
101
+ }
102
+ sequences.add(entry.sequence);
103
+ stored.push(entry);
104
+ }
105
+ }
106
+ await validate();
107
+ return stored;
108
+ }
109
+ export async function readJson(file, limit, expected, validate) {
110
+ try {
111
+ return JSON.parse(await readBoundedText(file, limit, {
112
+ label: "session file",
113
+ expected,
114
+ validate,
115
+ }));
116
+ }
117
+ catch (error) {
118
+ if (error instanceof SyntaxError) {
119
+ throw new Error("session file is not valid JSON");
120
+ }
121
+ if (error instanceof BoundedFileError) {
122
+ throw new Error("session file is unsafe or too large, or changed while opening");
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+ export async function removeTemporaryDirectory(directory, bucket, expected) {
128
+ const relative = path.relative(bucket.path, directory);
129
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
130
+ !path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
131
+ throw new Error("refusing to remove an unverified session directory");
132
+ await assertDirectoryAnchor(bucket);
133
+ let observed;
134
+ try {
135
+ observed = await captureDirectDirectory(directory, "temporary session directory");
136
+ }
137
+ catch (error) {
138
+ if (error.code === "ENOENT")
139
+ return;
140
+ throw error;
141
+ }
142
+ if (expected !== undefined &&
143
+ !sameFileIdentity(expected.identity, observed.identity))
144
+ throw new Error("refusing to remove a replaced session directory");
145
+ const quarantine = path.join(bucket.path, `.discard-${process.pid}-${randomUUID()}.tmp`);
146
+ await assertDirectoryAnchor(bucket);
147
+ await rename(directory, quarantine);
148
+ const moved = await captureDirectDirectory(quarantine, "discarded session directory");
149
+ if (!sameFileIdentity(observed.identity, moved.identity)) {
150
+ throw new Error("session cleanup target changed during quarantine");
151
+ }
152
+ await assertDirectoryAnchor(bucket);
153
+ await assertDirectoryAnchor(moved);
154
+ await rm(quarantine, { recursive: true });
155
+ }
156
+ export function nodeName(id) {
157
+ return `${String(id).padStart(6, "0")}.json`;
158
+ }
@@ -1,83 +1,166 @@
1
- // A tiny, bounded process lease for one durable conversation.
2
- import { randomUUID } from "node:crypto";
3
- import { lstat, open, unlink } from "node:fs/promises";
4
- const MAX_LEASE_BYTES = 256;
1
+ // A tiny, generation-safe process lease for one durable conversation.
2
+ import { lstat, unlink } from "node:fs/promises";
3
+ import { BoundedFileError, readBoundedText, stableFileExpectation, } from "../bounded-file.js";
4
+ import { fileIdentity, sameFileIdentity } from "../file-identity.js";
5
+ import { createProcessLeaseDirectory, inspectProcessLease, pidIsAlive, ProcessLeaseError, processLease, processLeaseToken, removeProcessLease, tryAcquireProcessLease, } from "../process-lease.js";
6
+ const LEGACY_LEASE_BYTES = 256;
7
+ const sessionLeaseScopes = new WeakMap();
5
8
  export function leaseToken() {
6
- return `${process.pid}:${randomUUID()}`;
9
+ return processLeaseToken();
7
10
  }
8
- export function sessionLease(id, file, token) {
11
+ export async function createLeaseDirectory(directory, token) {
12
+ return createProcessLeaseDirectory(directory, token);
13
+ }
14
+ export async function claimLeaseDirectory(directory, token) {
15
+ try {
16
+ return await tryAcquireProcessLease(directory, token, { staleMs: 0, setupGraceMs: 1_000 });
17
+ }
18
+ catch (error) {
19
+ if (error instanceof ProcessLeaseError)
20
+ throw unsafeLease();
21
+ throw error;
22
+ }
23
+ }
24
+ export function leaseFromGeneration(directory, generation) {
25
+ return processLease(directory, generation);
26
+ }
27
+ export function sessionLease(id, scope, lease) {
9
28
  let closed = false;
10
- return Object.freeze({
29
+ const owned = Object.freeze({
11
30
  id,
12
31
  assertOwned: async () => {
13
32
  if (closed)
14
33
  throw new Error("session lease is closed");
15
- const current = await readLease(file);
16
- if (current !== token)
34
+ try {
35
+ await lease.assertOwned();
36
+ }
37
+ catch {
17
38
  throw new Error("session lease is no longer owned by this process");
39
+ }
18
40
  },
19
41
  close: async () => {
20
42
  if (closed)
21
43
  return;
44
+ const removed = await lease.release();
45
+ if (!removed)
46
+ throw new Error("session lease is no longer owned by this process");
22
47
  closed = true;
23
- try {
24
- await removeLease(file, token);
25
- }
26
- catch {
27
- // A recovered, replaced, or already-closed lease is no longer ours.
28
- }
29
48
  },
30
49
  });
50
+ sessionLeaseScopes.set(owned, { id, scope });
51
+ return owned;
52
+ }
53
+ export function sessionLeaseOwns(lease, id, scope) {
54
+ const owner = sessionLeaseScopes.get(lease);
55
+ return owner?.id === id && owner.scope === scope;
31
56
  }
32
- export async function leaseOwner(file) {
57
+ export async function leaseOwner(directory, hooks = {}) {
33
58
  try {
34
- const token = await readLease(file);
35
- const match = /^([1-9]\d*):/.exec(token.trim());
36
- if (match === null)
37
- return { pid: 0, token };
38
- const pid = Number(match[1]);
39
- return { pid: Number.isSafeInteger(pid) ? pid : 0, token };
59
+ const owner = await inspectProcessLease(directory);
60
+ return owner === undefined
61
+ ? undefined
62
+ : { pid: owner.pid, token: owner.token, legacy: false };
40
63
  }
41
64
  catch (error) {
42
- if (error.code === "ENOENT")
43
- return undefined;
65
+ if (error instanceof ProcessLeaseError) {
66
+ let legacy;
67
+ try {
68
+ legacy = await legacyLeaseOwner(directory, hooks);
69
+ }
70
+ catch (legacyError) {
71
+ if (unsafeLegacyRace(legacyError))
72
+ throw unsafeLease();
73
+ throw legacyError;
74
+ }
75
+ if (legacy !== undefined)
76
+ return legacy;
77
+ throw unsafeLease();
78
+ }
44
79
  throw error;
45
80
  }
46
81
  }
47
- export async function removeLease(file, token) {
48
- const current = await readLease(file).catch(() => undefined);
49
- if (current === token)
50
- await unlink(file).catch(() => undefined);
82
+ export async function removeLease(directory, token) {
83
+ try {
84
+ return await removeProcessLease(directory, token);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof ProcessLeaseError)
88
+ throw unsafeLease();
89
+ throw error;
90
+ }
51
91
  }
52
- export function pidIsAlive(pid) {
53
- if (!Number.isSafeInteger(pid) || pid < 1 || pid > 0x7fff_ffff)
92
+ /** Remove a legacy fixed-file marker only while its session is exclusively owned. */
93
+ export async function removeLegacyLeaseExclusive(file, token, owner) {
94
+ if (!sessionLeaseScopes.has(owner)) {
95
+ throw new Error("legacy lease migration requires a Jecode session lease");
96
+ }
97
+ await owner.assertOwned();
98
+ let before;
99
+ try {
100
+ before = await lstat(file, { bigint: true });
101
+ }
102
+ catch (error) {
103
+ if (error.code === "ENOENT")
104
+ return true;
105
+ throw error;
106
+ }
107
+ if (before.isSymbolicLink() || !before.isFile() || before.size > BigInt(LEGACY_LEASE_BYTES)) {
54
108
  return false;
109
+ }
55
110
  try {
56
- process.kill(pid, 0);
111
+ const current = await readBoundedText(file, LEGACY_LEASE_BYTES, {
112
+ label: "legacy session lease",
113
+ expected: stableFileExpectation(before),
114
+ });
115
+ if (current !== token)
116
+ return false;
117
+ const after = await lstat(file, { bigint: true });
118
+ if (!sameFileIdentity(fileIdentity(before), fileIdentity(after)))
119
+ return false;
120
+ await owner.assertOwned();
121
+ await unlink(file);
57
122
  return true;
58
123
  }
59
124
  catch (error) {
60
- return error.code !== "ESRCH";
125
+ if (unsafeLegacyRace(error))
126
+ return false;
127
+ throw error;
61
128
  }
62
129
  }
63
- async function readLease(file) {
64
- const before = await lstat(file);
65
- if (before.isSymbolicLink() || !before.isFile() || before.size > MAX_LEASE_BYTES) {
66
- throw new Error("session lease is unsafe or too large");
67
- }
68
- const handle = await open(file, "r");
130
+ export { pidIsAlive };
131
+ function unsafeLease() {
132
+ return new Error("session lease is unsafe or too large, or invalid");
133
+ }
134
+ async function legacyLeaseOwner(file, hooks) {
135
+ let details;
69
136
  try {
70
- const opened = await handle.stat();
71
- if (!opened.isFile() || opened.size > MAX_LEASE_BYTES ||
72
- opened.dev !== before.dev || opened.ino !== before.ino)
73
- throw new Error("session lease changed while opening");
74
- const bytes = Buffer.alloc(MAX_LEASE_BYTES + 1);
75
- const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
76
- if (bytesRead > MAX_LEASE_BYTES)
77
- throw new Error("session lease is too large");
78
- return bytes.subarray(0, bytesRead).toString("utf8");
137
+ details = await lstat(file, { bigint: true });
79
138
  }
80
- finally {
81
- await handle.close();
139
+ catch (error) {
140
+ if (error.code === "ENOENT")
141
+ return undefined;
142
+ throw error;
143
+ }
144
+ if (details.isSymbolicLink() || !details.isFile() || details.size > BigInt(LEGACY_LEASE_BYTES)) {
145
+ return undefined;
82
146
  }
147
+ await hooks.afterLegacyStat?.();
148
+ const token = await readBoundedText(file, LEGACY_LEASE_BYTES, {
149
+ label: "legacy session lease",
150
+ expected: stableFileExpectation(details),
151
+ });
152
+ const match = /^([1-9]\d*):/.exec(token.trim());
153
+ const pid = match === null ? 0 : Number(match[1]);
154
+ return {
155
+ pid: Number.isSafeInteger(pid) && pid <= 0x7fff_ffff ? pid : 0,
156
+ token,
157
+ legacy: true,
158
+ };
159
+ }
160
+ function unsafeLegacyRace(error) {
161
+ if (error instanceof BoundedFileError)
162
+ return true;
163
+ const code = error.code;
164
+ return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP" ||
165
+ code === "EISDIR" || code === "ENXIO";
83
166
  }