@giovannijecha/jecode 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/assets/wordmark-steel.svg +3 -0
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch.js +49 -4
- package/dist/bounded-file.js +212 -0
- package/dist/commands.js +2 -0
- package/dist/config.js +8 -4
- package/dist/context/automatic.js +35 -0
- package/dist/context/compactor.js +32 -2
- package/dist/context/manual.js +20 -3
- package/dist/context/request-projection.js +130 -0
- package/dist/controller-request.js +33 -11
- package/dist/credential-commands.js +22 -6
- package/dist/credentials.js +56 -18
- package/dist/directory-anchor.js +91 -0
- package/dist/file-identity.js +12 -0
- package/dist/model-command.js +5 -4
- package/dist/openai-account-command.js +13 -2
- package/dist/process-lease.js +329 -0
- package/dist/provider-commands.js +53 -10
- package/dist/provider-errors.js +94 -3
- package/dist/provider-label.js +13 -0
- package/dist/providers/anthropic-stream.js +4 -1
- package/dist/providers/anthropic-wire.js +8 -3
- package/dist/providers/anthropic.js +39 -19
- package/dist/providers/catalog.js +4 -4
- package/dist/providers/failure.js +181 -0
- package/dist/providers/http.js +86 -57
- package/dist/providers/ollama-stream.js +5 -1
- package/dist/providers/ollama.js +31 -19
- package/dist/providers/openai-codex.js +67 -41
- package/dist/providers/openai-stream.js +69 -9
- package/dist/providers/openai.js +51 -24
- package/dist/providers/sse.js +89 -17
- package/dist/request-identity.js +32 -0
- package/dist/sessions/catalog.js +199 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/store.js +451 -183
- package/dist/settings.js +62 -10
- package/dist/stable-directory.js +148 -0
- package/dist/store-lock.js +68 -84
- package/dist/tools/args.js +2 -2
- package/dist/tools/fs.js +124 -102
- package/dist/tools/search.js +81 -107
- package/dist/tools/text-boundary.js +7 -33
- package/dist/tui/app-workflows.js +32 -4
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/feedback.js +4 -0
- package/dist/tui/session-view.js +7 -2
- package/dist/tui/workspace.js +21 -7
- package/dist/user-store.js +23 -31
- package/package.json +4 -4
- package/dist/tools/ripgrep.js +0 -230
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Small, head-tied projections for the resume catalogue.
|
|
2
|
+
//
|
|
3
|
+
// Conversation nodes remain authoritative and are fully decoded when a
|
|
4
|
+
// session is selected. This auxiliary record lets listing stay independent of
|
|
5
|
+
// every unselected tree's depth; a missing or suspect record falls back to the
|
|
6
|
+
// strict loader and can be rebuilt without changing the session schema.
|
|
7
|
+
import { Buffer } from "node:buffer";
|
|
8
|
+
import { CONVERSATION_LIMITS } from "../conversation.js";
|
|
9
|
+
import { leadingText } from "../text-boundary.js";
|
|
10
|
+
import { decodeHead, encodeHead } from "./codec.js";
|
|
11
|
+
export const SESSION_CATALOG_FILE = "catalog.json";
|
|
12
|
+
export const SESSION_CHECKPOINT_FILE = ".checkpoint";
|
|
13
|
+
export const SESSION_CATALOG_BYTES = 4 * 1_024;
|
|
14
|
+
const CATALOG_SCHEMA = 1;
|
|
15
|
+
const PREVIEW_CODE_UNITS = 160;
|
|
16
|
+
export function sessionCatalog(meta, head, conversation) {
|
|
17
|
+
assertActiveHead(head, conversation);
|
|
18
|
+
const resumable = conversation.latestResumable();
|
|
19
|
+
return own({
|
|
20
|
+
version: CATALOG_SCHEMA,
|
|
21
|
+
id: meta.id,
|
|
22
|
+
workspaceDigest: meta.workspaceDigest,
|
|
23
|
+
createdAt: meta.createdAt,
|
|
24
|
+
head,
|
|
25
|
+
resumeNodeId: resumable?.activeNodeId ?? 0,
|
|
26
|
+
turns: resumable === undefined ? 0 : selectedTurnCount(resumable),
|
|
27
|
+
preview: resumable === undefined ? "" : firstUserText(resumable),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Advance the common linear checkpoint path without walking prior turns. */
|
|
31
|
+
export function advanceSessionCatalog(previous, meta, head, conversation) {
|
|
32
|
+
assertActiveHead(head, conversation);
|
|
33
|
+
if (previous.id !== meta.id ||
|
|
34
|
+
previous.workspaceDigest !== meta.workspaceDigest ||
|
|
35
|
+
previous.createdAt !== meta.createdAt)
|
|
36
|
+
throw new Error("session catalogue does not match its metadata");
|
|
37
|
+
const active = conversation.activeNode;
|
|
38
|
+
const revisesHead = active.id === previous.head.nodeId &&
|
|
39
|
+
active.parentId === previous.head.parentId &&
|
|
40
|
+
active.revision === previous.head.revision + 1;
|
|
41
|
+
const extendsHead = active.parentId === previous.head.nodeId &&
|
|
42
|
+
active.id > previous.head.nodeId && active.revision === 1;
|
|
43
|
+
if (!revisesHead && !extendsHead)
|
|
44
|
+
return sessionCatalog(meta, head, conversation);
|
|
45
|
+
// A resumable child of an unfinished head makes that intermediate node part
|
|
46
|
+
// of the selected path. Rebuild in that uncommon case so the turn count is
|
|
47
|
+
// exact instead of assuming one visible turn was appended.
|
|
48
|
+
if (extendsHead && active.settlement !== "checkpointed" &&
|
|
49
|
+
previous.resumeNodeId !== previous.head.nodeId)
|
|
50
|
+
return sessionCatalog(meta, head, conversation);
|
|
51
|
+
if (active.settlement === "checkpointed" && previous.resumeNodeId === active.id) {
|
|
52
|
+
return sessionCatalog(meta, head, conversation);
|
|
53
|
+
}
|
|
54
|
+
const resumable = active.settlement !== "checkpointed";
|
|
55
|
+
const addsTurn = resumable && previous.resumeNodeId !== active.id;
|
|
56
|
+
const turns = previous.turns + (addsTurn ? 1 : 0);
|
|
57
|
+
const preview = turns === 0
|
|
58
|
+
? ""
|
|
59
|
+
: previous.turns === 0
|
|
60
|
+
? firstUserTextInNode(active)
|
|
61
|
+
: previous.preview;
|
|
62
|
+
return own({
|
|
63
|
+
version: CATALOG_SCHEMA,
|
|
64
|
+
id: meta.id,
|
|
65
|
+
workspaceDigest: meta.workspaceDigest,
|
|
66
|
+
createdAt: meta.createdAt,
|
|
67
|
+
head,
|
|
68
|
+
resumeNodeId: resumable ? active.id : previous.resumeNodeId,
|
|
69
|
+
turns,
|
|
70
|
+
preview,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function encodeSessionCatalog(catalog) {
|
|
74
|
+
const encoded = `${JSON.stringify(catalog, null, 2)}\n`;
|
|
75
|
+
decodeSessionCatalog(JSON.parse(encoded));
|
|
76
|
+
if (Buffer.byteLength(encoded, "utf8") > SESSION_CATALOG_BYTES) {
|
|
77
|
+
throw new Error("session catalogue data is invalid or unsupported");
|
|
78
|
+
}
|
|
79
|
+
return encoded;
|
|
80
|
+
}
|
|
81
|
+
export function decodeSessionCatalog(value) {
|
|
82
|
+
if (!record(value) || !keys(value, "createdAt,head,id,preview,resumeNodeId,turns,version,workspaceDigest"))
|
|
83
|
+
throw invalid();
|
|
84
|
+
const head = decodeHead(value["head"]);
|
|
85
|
+
const resumeNodeId = value["resumeNodeId"];
|
|
86
|
+
const turns = value["turns"];
|
|
87
|
+
const preview = value["preview"];
|
|
88
|
+
if (value["version"] !== CATALOG_SCHEMA ||
|
|
89
|
+
!identifier(value["id"]) ||
|
|
90
|
+
!digest(value["workspaceDigest"]) ||
|
|
91
|
+
!timestamp(value["createdAt"]) ||
|
|
92
|
+
!integer(resumeNodeId, 0) || resumeNodeId > head.nodeId ||
|
|
93
|
+
!integer(turns, 0) || turns > CONVERSATION_LIMITS.nodes || turns > resumeNodeId ||
|
|
94
|
+
typeof preview !== "string" || preview.length > PREVIEW_CODE_UNITS ||
|
|
95
|
+
(resumeNodeId === 0) !== (turns === 0) ||
|
|
96
|
+
(turns === 0 ? preview !== "" : preview === ""))
|
|
97
|
+
throw invalid();
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
version: CATALOG_SCHEMA,
|
|
100
|
+
id: value["id"],
|
|
101
|
+
workspaceDigest: value["workspaceDigest"],
|
|
102
|
+
createdAt: value["createdAt"],
|
|
103
|
+
head,
|
|
104
|
+
resumeNodeId,
|
|
105
|
+
turns,
|
|
106
|
+
preview,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export function catalogMatches(catalog, meta, head) {
|
|
110
|
+
return catalog.id === meta.id &&
|
|
111
|
+
catalog.workspaceDigest === meta.workspaceDigest &&
|
|
112
|
+
catalog.createdAt === meta.createdAt &&
|
|
113
|
+
sameSessionHead(catalog.head, head);
|
|
114
|
+
}
|
|
115
|
+
export function sameSessionHead(left, right) {
|
|
116
|
+
return left.version === right.version &&
|
|
117
|
+
left.sequence === right.sequence &&
|
|
118
|
+
left.nodeId === right.nodeId &&
|
|
119
|
+
left.parentId === right.parentId &&
|
|
120
|
+
left.revision === right.revision &&
|
|
121
|
+
left.updatedAt === right.updatedAt;
|
|
122
|
+
}
|
|
123
|
+
function own(value) {
|
|
124
|
+
encodeHead(value.head);
|
|
125
|
+
return decodeSessionCatalog(structuredClone(value));
|
|
126
|
+
}
|
|
127
|
+
function assertActiveHead(head, conversation) {
|
|
128
|
+
const active = conversation.activeNode;
|
|
129
|
+
if (active === undefined || active.id !== head.nodeId ||
|
|
130
|
+
active.parentId !== head.parentId || active.revision !== head.revision)
|
|
131
|
+
throw new Error("session catalogue does not match its conversation head");
|
|
132
|
+
}
|
|
133
|
+
function selectedTurnCount(conversation) {
|
|
134
|
+
let count = 0;
|
|
135
|
+
let id = conversation.activeNodeId;
|
|
136
|
+
while (id !== 0) {
|
|
137
|
+
count++;
|
|
138
|
+
id = conversation.node(id)?.parentId ?? 0;
|
|
139
|
+
}
|
|
140
|
+
return count;
|
|
141
|
+
}
|
|
142
|
+
function firstUserText(conversation) {
|
|
143
|
+
const path = [];
|
|
144
|
+
let id = conversation.activeNodeId;
|
|
145
|
+
while (id !== 0) {
|
|
146
|
+
const node = conversation.node(id);
|
|
147
|
+
if (node === undefined)
|
|
148
|
+
throw new Error("session catalogue path is incomplete");
|
|
149
|
+
path.push(node);
|
|
150
|
+
id = node.parentId;
|
|
151
|
+
}
|
|
152
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
153
|
+
const preview = userTextInNode(path[index]);
|
|
154
|
+
if (preview !== undefined)
|
|
155
|
+
return preview;
|
|
156
|
+
}
|
|
157
|
+
return "Untitled session";
|
|
158
|
+
}
|
|
159
|
+
function firstUserTextInNode(node) {
|
|
160
|
+
return userTextInNode(node) ?? "Untitled session";
|
|
161
|
+
}
|
|
162
|
+
function userTextInNode(node) {
|
|
163
|
+
for (const message of node.messages) {
|
|
164
|
+
if (message.role !== "user")
|
|
165
|
+
continue;
|
|
166
|
+
const preview = textPreview(message.content.find((block) => block.kind === "text")?.text);
|
|
167
|
+
if (preview !== undefined)
|
|
168
|
+
return preview;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
function textPreview(text) {
|
|
173
|
+
const normalized = text?.replace(/\s+/gu, " ").trim();
|
|
174
|
+
return normalized === undefined || normalized === ""
|
|
175
|
+
? undefined
|
|
176
|
+
: leadingText(normalized, PREVIEW_CODE_UNITS);
|
|
177
|
+
}
|
|
178
|
+
function keys(value, expected) {
|
|
179
|
+
return Object.keys(value).sort().join(",") === expected;
|
|
180
|
+
}
|
|
181
|
+
function record(value) {
|
|
182
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
183
|
+
}
|
|
184
|
+
function integer(value, minimum) {
|
|
185
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
|
|
186
|
+
}
|
|
187
|
+
function identifier(value) {
|
|
188
|
+
return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
|
|
189
|
+
}
|
|
190
|
+
function digest(value) {
|
|
191
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
192
|
+
}
|
|
193
|
+
function timestamp(value) {
|
|
194
|
+
return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
|
|
195
|
+
Number.isFinite(Date.parse(value));
|
|
196
|
+
}
|
|
197
|
+
function invalid() {
|
|
198
|
+
return new Error("session catalogue data is invalid or unsupported");
|
|
199
|
+
}
|
package/dist/sessions/lease.js
CHANGED
|
@@ -1,83 +1,166 @@
|
|
|
1
|
-
// A tiny,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
|
9
|
+
return processLeaseToken();
|
|
7
10
|
}
|
|
8
|
-
export function
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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(
|
|
57
|
+
export async function leaseOwner(directory, hooks = {}) {
|
|
33
58
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
43
|
-
|
|
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(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
+
if (unsafeLegacyRace(error))
|
|
126
|
+
return false;
|
|
127
|
+
throw error;
|
|
61
128
|
}
|
|
62
129
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
}
|
package/dist/sessions/runtime.js
CHANGED
|
@@ -3,36 +3,38 @@
|
|
|
3
3
|
// One logical conversation keeps one stable session id across every resume.
|
|
4
4
|
// New turns advance that session's tree head; /new is the boundary that starts
|
|
5
5
|
// another durable session.
|
|
6
|
-
import { DurableSessionStore } from "./store.js";
|
|
6
|
+
import { DurableSessionStore, reserveSessionId } from "./store.js";
|
|
7
7
|
export class SessionPersistence {
|
|
8
8
|
#store;
|
|
9
9
|
#sessionId;
|
|
10
|
+
#conversationId;
|
|
10
11
|
#lease;
|
|
11
12
|
#snapshot;
|
|
12
13
|
#failure;
|
|
13
|
-
constructor(store, sessionId, lease, snapshot) {
|
|
14
|
+
constructor(store, sessionId, lease, snapshot, conversationId = sessionId ?? reserveSessionId()) {
|
|
14
15
|
this.#store = store;
|
|
15
16
|
this.#sessionId = sessionId;
|
|
17
|
+
this.#conversationId = conversationId;
|
|
16
18
|
this.#lease = lease;
|
|
17
19
|
this.#snapshot = snapshot;
|
|
18
20
|
}
|
|
19
21
|
static fresh(store) {
|
|
20
|
-
return new SessionPersistence(store, null);
|
|
22
|
+
return new SessionPersistence(store, null, undefined, undefined, reserveSessionId());
|
|
21
23
|
}
|
|
22
24
|
static async resume(store, id) {
|
|
23
25
|
const lease = await store.claim(id);
|
|
24
26
|
try {
|
|
25
|
-
const snapshot = await store.load(id);
|
|
27
|
+
const snapshot = await store.load(id, lease);
|
|
26
28
|
const conversation = snapshot.conversation.latestResumable();
|
|
27
29
|
if (conversation === undefined)
|
|
28
30
|
throw new Error("session has no resumable turn");
|
|
29
31
|
return Object.freeze({
|
|
30
32
|
conversation,
|
|
31
|
-
persistence: new SessionPersistence(store, id, lease, snapshot),
|
|
33
|
+
persistence: new SessionPersistence(store, id, lease, snapshot, id),
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
36
|
catch (error) {
|
|
35
|
-
await lease.close();
|
|
37
|
+
await lease.close().catch(() => undefined);
|
|
36
38
|
throw error;
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -45,12 +47,16 @@ export class SessionPersistence {
|
|
|
45
47
|
get sessionId() {
|
|
46
48
|
return this.#sessionId;
|
|
47
49
|
}
|
|
50
|
+
/** Reserved before publication so provider cache identity survives resume. */
|
|
51
|
+
get conversationId() {
|
|
52
|
+
return this.#conversationId;
|
|
53
|
+
}
|
|
48
54
|
async checkpoint(conversation) {
|
|
49
55
|
if (this.#failure !== undefined)
|
|
50
56
|
throw this.#failure;
|
|
51
57
|
try {
|
|
52
58
|
if (this.#sessionId === null) {
|
|
53
|
-
const published = await this.#store.publish(conversation, true);
|
|
59
|
+
const published = await this.#store.publish(conversation, true, this.#conversationId);
|
|
54
60
|
this.#lease = published.lease;
|
|
55
61
|
this.#sessionId = published.meta.id;
|
|
56
62
|
this.#snapshot = published;
|
|
@@ -60,7 +66,7 @@ export class SessionPersistence {
|
|
|
60
66
|
throw new Error("session persistence has no verified owner snapshot");
|
|
61
67
|
}
|
|
62
68
|
await this.#lease.assertOwned();
|
|
63
|
-
this.#snapshot = await this.#store.checkpoint(this.#snapshot, conversation);
|
|
69
|
+
this.#snapshot = await this.#store.checkpoint(this.#snapshot, conversation, this.#lease);
|
|
64
70
|
}
|
|
65
71
|
catch (error) {
|
|
66
72
|
this.#failure = error;
|
|
@@ -71,6 +77,7 @@ export class SessionPersistence {
|
|
|
71
77
|
await this.#lease?.close();
|
|
72
78
|
this.#lease = undefined;
|
|
73
79
|
this.#sessionId = null;
|
|
80
|
+
this.#conversationId = reserveSessionId();
|
|
74
81
|
this.#snapshot = undefined;
|
|
75
82
|
this.#failure = undefined;
|
|
76
83
|
}
|