@giovannijecha/jecode 0.8.5 → 0.8.7

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 (104) hide show
  1. package/README.md +36 -9
  2. package/assets/tokenizers/LICENSE +21 -0
  3. package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
  4. package/dist/cli-info.js +8 -14
  5. package/dist/commands.js +7 -3
  6. package/dist/config.js +47 -53
  7. package/dist/context/automatic.js +16 -1
  8. package/dist/context/budget.js +5 -2
  9. package/dist/context/compactor.js +91 -37
  10. package/dist/context/diagnostics.js +108 -0
  11. package/dist/context/lifetime.js +18 -0
  12. package/dist/context/manager.js +67 -0
  13. package/dist/context/manual.js +11 -10
  14. package/dist/context/measurement.js +75 -0
  15. package/dist/context/policy.js +13 -10
  16. package/dist/context/request-observation.js +46 -0
  17. package/dist/context/request-projection.js +8 -34
  18. package/dist/context/request.js +22 -0
  19. package/dist/context/tokenizer/bpe.js +68 -0
  20. package/dist/context/tokenizer/o200k.js +54 -0
  21. package/dist/context/tokenizer/vocabulary.js +34 -0
  22. package/dist/controller-request.js +47 -45
  23. package/dist/controller.js +13 -2
  24. package/dist/credential-commands.js +3 -3
  25. package/dist/input-boundary.js +0 -62
  26. package/dist/launch.js +13 -9
  27. package/dist/model-command.js +7 -17
  28. package/dist/oauth-result-page.js +68 -0
  29. package/dist/openai-account-command.js +14 -12
  30. package/dist/openai-account.js +7 -5
  31. package/dist/openai-oauth-callback.js +15 -54
  32. package/dist/openai-oauth-tokens.js +9 -7
  33. package/dist/openai-oauth.js +8 -6
  34. package/dist/permission-command.js +9 -24
  35. package/dist/permissions.js +10 -8
  36. package/dist/provider-commands.js +1 -33
  37. package/dist/provider-errors.js +1 -10
  38. package/dist/provider-label.js +3 -10
  39. package/dist/providers/anthropic-wire.js +1 -1
  40. package/dist/providers/anthropic.js +3 -2
  41. package/dist/providers/index.js +1 -5
  42. package/dist/providers/input-measurement.js +85 -0
  43. package/dist/providers/ollama-context.js +42 -0
  44. package/dist/providers/ollama-endpoint.js +7 -34
  45. package/dist/providers/ollama-wire.js +1 -1
  46. package/dist/providers/ollama.js +15 -147
  47. package/dist/providers/openai-codex.js +7 -4
  48. package/dist/providers/openai-stream.js +20 -8
  49. package/dist/providers/openai-summary.js +37 -0
  50. package/dist/providers/openai-wire.js +1 -1
  51. package/dist/providers/openai.js +3 -1
  52. package/dist/sessions/bucket.js +55 -0
  53. package/dist/sessions/catalog-io.js +162 -0
  54. package/dist/sessions/catalog.js +3 -1
  55. package/dist/sessions/codec-messages.js +122 -0
  56. package/dist/sessions/codec-transcript.js +93 -0
  57. package/dist/sessions/codec-values.js +52 -0
  58. package/dist/sessions/codec.js +4 -257
  59. package/dist/sessions/files.js +158 -0
  60. package/dist/sessions/load.js +90 -0
  61. package/dist/sessions/snapshot.js +33 -0
  62. package/dist/sessions/store.js +42 -461
  63. package/dist/settings.js +17 -16
  64. package/dist/start.js +36 -53
  65. package/dist/timeline.js +2 -0
  66. package/dist/tools/file-read.js +192 -0
  67. package/dist/tools/file-summary.js +9 -0
  68. package/dist/tools/{fs.js → file-write.js} +5 -193
  69. package/dist/tools/glob.js +107 -0
  70. package/dist/tools/index.js +2 -1
  71. package/dist/tools/search.js +1 -105
  72. package/dist/tui/activity.js +1 -1
  73. package/dist/tui/app-input.js +34 -17
  74. package/dist/tui/app-workflows.js +13 -361
  75. package/dist/tui/app.js +55 -28
  76. package/dist/tui/approve.js +3 -2
  77. package/dist/tui/blocks.js +1 -2
  78. package/dist/tui/command-workflow.js +112 -0
  79. package/dist/tui/components/command-menu.js +7 -10
  80. package/dist/tui/components/menu.js +82 -43
  81. package/dist/tui/components/messages.js +16 -9
  82. package/dist/tui/components/status.js +1 -1
  83. package/dist/tui/components/tool-evidence.js +107 -0
  84. package/dist/tui/components/tool-motion.js +32 -0
  85. package/dist/tui/components/tool.js +48 -202
  86. package/dist/tui/frame.js +8 -3
  87. package/dist/tui/help.js +3 -2
  88. package/dist/tui/picker-layout.js +44 -0
  89. package/dist/tui/picker.js +7 -71
  90. package/dist/tui/screen.js +7 -0
  91. package/dist/tui/session-view.js +0 -1
  92. package/dist/tui/tool-details.js +135 -0
  93. package/dist/tui/transcript-grammar.js +1 -1
  94. package/dist/tui/transcript-view.js +30 -112
  95. package/dist/tui/turn-workflow.js +235 -0
  96. package/dist/tui/turn.js +7 -140
  97. package/dist/tui/view.js +4 -4
  98. package/dist/tui/workflow-types.js +2 -0
  99. package/dist/ui/render.js +0 -4
  100. package/package.json +16 -12
  101. package/dist/batch-view.js +0 -17
  102. package/dist/batch.js +0 -267
  103. package/dist/ollama-settings-command.js +0 -74
  104. 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
+ }
@@ -0,0 +1,90 @@
1
+ // Strict full-session loading and one adjacent checkpoint recovery.
2
+ import * as path from "node:path";
3
+ import { atomicWrite } from "../atomic.js";
4
+ import { ConversationTree } from "../conversation.js";
5
+ import { assertDirectoryAnchor, captureDirectDirectory } from "../directory-anchor.js";
6
+ import { assertSessionId } from "./bucket.js";
7
+ import { sameSessionHead, sessionCatalog } from "./catalog.js";
8
+ import { decodeHead, decodeMeta, encodeHead, SESSION_FILE_LIMITS, SESSION_SCHEMA } from "./codec.js";
9
+ import { FILE_MODE, readJson, readNodes } from "./files.js";
10
+ import { sessionLeaseOwns } from "./lease.js";
11
+ export async function loadSession(bucket, id, scope, recoveryLease) {
12
+ assertSessionId(id);
13
+ await bucket.assert();
14
+ const directory = bucket.directory(id);
15
+ const directoryAnchor = await captureDirectDirectory(directory, "session directory");
16
+ const nodesAnchor = await captureDirectDirectory(path.join(directory, "nodes"), "session node directory");
17
+ const validateSession = async () => {
18
+ await Promise.all([
19
+ bucket.assert(),
20
+ assertDirectoryAnchor(directoryAnchor),
21
+ assertDirectoryAnchor(nodesAnchor),
22
+ ]);
23
+ };
24
+ const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
25
+ bucket.assertWorkspace(meta, id);
26
+ const persistedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
27
+ let head = persistedHead;
28
+ const stored = await readNodes(nodesAnchor, validateSession);
29
+ const ahead = stored.filter((entry) => entry.sequence > head.sequence);
30
+ if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
31
+ throw new Error("session has an ambiguous incomplete checkpoint");
32
+ }
33
+ const byId = new Map(stored.map((entry) => [entry.node.id, entry]));
34
+ const headed = byId.get(head.nodeId);
35
+ if (headed === undefined)
36
+ throw new Error("session head is missing its conversation node");
37
+ if (ahead.length === 0) {
38
+ if (headed.sequence !== head.sequence || headed.node.revision !== head.revision ||
39
+ headed.node.parentId !== head.parentId) {
40
+ throw new Error("session head does not match its conversation node");
41
+ }
42
+ }
43
+ else {
44
+ const candidate = ahead[0];
45
+ const replacesHead = candidate.node.id === head.nodeId &&
46
+ candidate.node.parentId === head.parentId &&
47
+ candidate.node.revision === head.revision + 1;
48
+ const candidateParent = byId.get(candidate.node.parentId);
49
+ const extendsTree = candidate.node.id === stored.length &&
50
+ candidate.node.revision === 1 && candidateParent !== undefined &&
51
+ candidateParent.sequence <= head.sequence;
52
+ if (!replacesHead && !extendsTree) {
53
+ throw new Error("session checkpoint cannot be recovered safely");
54
+ }
55
+ if (recoveryLease === undefined ||
56
+ !sessionLeaseOwns(recoveryLease, id, scope)) {
57
+ throw new Error("session recovery requires exclusive ownership");
58
+ }
59
+ await recoveryLease.assertOwned();
60
+ head = Object.freeze({
61
+ version: SESSION_SCHEMA,
62
+ sequence: candidate.sequence,
63
+ nodeId: candidate.node.id,
64
+ parentId: candidate.node.parentId,
65
+ revision: candidate.node.revision,
66
+ updatedAt: candidate.updatedAt,
67
+ });
68
+ }
69
+ // Recovery must pass all turn, tree, and catalogue invariants before it
70
+ // changes durable state; the file codec alone cannot validate the tree.
71
+ const nodes = stored.map((entry) => entry.node);
72
+ const conversation = ConversationTree.restore(nodes, head.nodeId);
73
+ const catalog = sessionCatalog(meta, head, conversation);
74
+ if (head !== persistedHead && recoveryLease !== undefined) {
75
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
76
+ mode: FILE_MODE,
77
+ validate: async (phase) => {
78
+ await validateSession();
79
+ if (phase !== "before-rename")
80
+ return;
81
+ await recoveryLease.assertOwned();
82
+ const current = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
83
+ if (!sameSessionHead(current, persistedHead)) {
84
+ throw new Error("session head changed while recovering its checkpoint");
85
+ }
86
+ },
87
+ });
88
+ }
89
+ return Object.freeze({ meta, head, conversation, catalog });
90
+ }
@@ -0,0 +1,33 @@
1
+ // Verified in-memory checkpoint identity shared by persistence operations.
2
+ import { catalogMatches, encodeSessionCatalog } from "./catalog.js";
3
+ import { encodeHead, encodeMeta } from "./codec.js";
4
+ import { assertSessionId, workspaceKey } from "./bucket.js";
5
+ export function assertSharedNodes(previous, next, replacedId) {
6
+ for (const node of previous.nodes) {
7
+ if (node.id === replacedId)
8
+ continue;
9
+ const candidate = next.node(node.id);
10
+ if (candidate !== node) {
11
+ throw new Error("session checkpoint rewrites prior conversation history");
12
+ }
13
+ }
14
+ }
15
+ export function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
16
+ encodeMeta(snapshot.meta);
17
+ encodeHead(snapshot.head);
18
+ encodeSessionCatalog(snapshot.catalog);
19
+ assertSessionId(snapshot.meta.id);
20
+ if (snapshot.meta.workspaceDigest !== workspaceDigest ||
21
+ workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
22
+ throw new Error("session snapshot belongs to a different workspace");
23
+ const active = snapshot.conversation.activeNode;
24
+ if (active === undefined ||
25
+ active.id !== snapshot.head.nodeId ||
26
+ active.parentId !== snapshot.head.parentId ||
27
+ active.revision !== snapshot.head.revision ||
28
+ snapshot.head.sequence < snapshot.conversation.nodes.length)
29
+ throw new Error("session snapshot does not match its verified head");
30
+ if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
31
+ throw new Error("session snapshot does not match its verified catalogue");
32
+ }
33
+ }