@sublang/playbook 17.11.0 → 18.0.0

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.
@@ -0,0 +1,291 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ // Exact replay bytes live in immutable chunks. The small root is the only
5
+ // mutable entry; publication never modifies a previously readable chain.
6
+ import { randomUUID } from 'node:crypto';
7
+ import { constants } from 'node:fs';
8
+ import * as nativeFs from 'node:fs/promises';
9
+ import { isAbsolute, join } from 'node:path';
10
+ import { createSessionChunks, MAX_SESSION_CHUNK_BYTES } from './session-chunks.js';
11
+ import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry } from './private-paths.js';
12
+
13
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
14
+ const DIGEST = /^[0-9a-f]{64}$/;
15
+ const exactKeys = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) &&
16
+ Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
17
+ const count = (value) => Number.isSafeInteger(value) && value >= 0;
18
+ const invalid = () => { throw new Error('invalid bounded session replay'); };
19
+ function reference(value) {
20
+ if (!exactKeys(value, ['sha256', 'byteLength']) || !DIGEST.test(value.sha256) ||
21
+ !count(value.byteLength) || value.byteLength < 1 || value.byteLength > MAX_SESSION_CHUNK_BYTES) invalid();
22
+ return value;
23
+ }
24
+ function privateStat(stat, directory) {
25
+ verifyPrivateEntry(stat, directory, 'session replay');
26
+ if ((stat.mode & 0o7777) !== (directory ? 0o700 : 0o600)) throw new Error('session replay permissions must be private');
27
+ }
28
+ const unchanged = (left, right) => sameFileIdentity(left, right) && left.size === right.size &&
29
+ left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
30
+ function rootChanged(message) {
31
+ const error = new Error(message);
32
+ error.replayRootRetry = true;
33
+ return error;
34
+ }
35
+ function privateRootStat(stat) {
36
+ try { privateStat(stat, false); }
37
+ catch (error) {
38
+ // A complete initial root can briefly have its staging link, and an
39
+ // already opened old root loses its last link on atomic replacement.
40
+ // Retry those observations; a persistent unsafe link still fails closed.
41
+ if (stat.nlink !== 1) error.replayRootRetry = true;
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, assertOwner = async () => {}, chunks }) {
47
+ if (!UUID.test(sessionId)) throw new TypeError('session replay requires a canonical session UUID');
48
+ if (typeof sessionsDir !== 'string' || !isAbsolute(sessionsDir) || sessionsDir.includes('\0')) throw new TypeError('session replay directory must be absolute');
49
+ if (typeof assertOwner !== 'function') throw new TypeError('session replay owner check must be a function');
50
+ const fs = { ...nativeFs, ...fsOps };
51
+ const path = join(sessionsDir, `${sessionId}.replay.json`);
52
+ const chunkStore = chunks ?? createSessionChunks({ directory: join(sessionsDir, `${sessionId}.chunks`), fsOps });
53
+ let queue = Promise.resolve();
54
+ // This constant-size proof is private to one backend / writer lease. It
55
+ // establishes durability of append-only dependencies, not immunity to an
56
+ // outside writer changing an immutable chunk. Public reads always verify.
57
+ let publicationProof = null;
58
+ let mutationAnchor = null;
59
+ const proofOf = (snapshot) => ({
60
+ owner: snapshot.owner, stat: snapshot.stat,
61
+ rootBytes: snapshot.rootBytes === null ? null : Buffer.from(snapshot.rootBytes),
62
+ });
63
+ const assertMutationAnchor = (snapshot) => {
64
+ if (mutationAnchor === null) return;
65
+ if (!sameFileIdentity(mutationAnchor.owner, snapshot.owner) ||
66
+ (mutationAnchor.rootBytes === null ? snapshot.rootBytes !== null : snapshot.rootBytes === null ||
67
+ !unchanged(mutationAnchor.stat, snapshot.stat) || !mutationAnchor.rootBytes.equals(snapshot.rootBytes))) {
68
+ throw new Error('session replay root changed outside its writer');
69
+ }
70
+ };
71
+ const matchesProof = (snapshot) => publicationProof !== null && snapshot.rootBytes !== null &&
72
+ sameFileIdentity(publicationProof.owner, snapshot.owner) && unchanged(publicationProof.stat, snapshot.stat) &&
73
+ publicationProof.rootBytes.equals(snapshot.rootBytes);
74
+ const rememberProof = (snapshot) => {
75
+ if (mutationAnchor === null) mutationAnchor = proofOf(snapshot);
76
+ if (snapshot.rootBytes !== null) publicationProof = proofOf(snapshot);
77
+ };
78
+ const serialize = (operation) => {
79
+ const result = queue.then(operation);
80
+ queue = result.catch(() => {});
81
+ return result;
82
+ };
83
+ const directory = async () => {
84
+ const stat = await fs.lstat(sessionsDir); privateStat(stat, true); return stat;
85
+ };
86
+ const syncDirectory = async (expected) => {
87
+ const handle = await fs.open(sessionsDir, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_DIRECTORY ?? 0));
88
+ try {
89
+ const stat = await handle.stat(); privateStat(stat, true);
90
+ if (!sameFileIdentity(expected, stat)) throw new Error('session replay directory changed');
91
+ await handle.sync();
92
+ if (!sameFileIdentity(stat, await directory())) throw new Error('session replay directory changed');
93
+ } finally { await handle.close(); }
94
+ };
95
+ const validateRoot = (value) => {
96
+ if (!exactKeys(value, ['kind', 'schemaVersion', 'sessionId', 'head', 'byteLength']) ||
97
+ value.kind !== 'captain-session-replay' || value.schemaVersion !== 1 || value.sessionId !== sessionId ||
98
+ !count(value.byteLength) || (value.head === null) !== (value.byteLength === 0)) invalid();
99
+ if (value.head !== null) reference(value.head);
100
+ return value;
101
+ };
102
+ const readRootOnce = async () => {
103
+ const owner = await directory();
104
+ let before;
105
+ try { before = await fs.lstat(path); }
106
+ catch (error) { if (error.code === 'ENOENT') return { root: null, rootBytes: null, stat: null, owner }; throw error; }
107
+ privateRootStat(before);
108
+ if (before.size > MAX_SESSION_CHUNK_BYTES) throw new Error('session replay root exceeds file bound');
109
+ const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
110
+ try {
111
+ const opened = await handle.stat(); privateRootStat(opened);
112
+ if (!unchanged(before, opened)) throw rootChanged('session replay root changed before read');
113
+ const bytes = Buffer.alloc(opened.size);
114
+ let offset = 0;
115
+ while (offset < bytes.length) {
116
+ const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
117
+ if (!bytesRead) throw rootChanged('session replay root truncated during read');
118
+ offset += bytesRead;
119
+ }
120
+ const after = await handle.stat(); privateRootStat(after);
121
+ if (!unchanged(opened, after)) throw rootChanged('session replay root changed during read');
122
+ if (!sameFileIdentity(owner, await directory())) throw new Error('session replay directory changed during read');
123
+ return { root: validateRoot(JSON.parse(bytes.toString('utf8'))), rootBytes: bytes, stat: after, owner };
124
+ } finally { await handle.close(); }
125
+ };
126
+ const readRoot = async () => {
127
+ for (let attempt = 0; ; attempt++) {
128
+ try { return await readRootOnce(); }
129
+ catch (error) { if (!error.replayRootRetry || attempt === 3) throw error; }
130
+ }
131
+ };
132
+ const assertSnapshot = async (snapshot) => {
133
+ const current = await readRoot();
134
+ if (!sameFileIdentity(snapshot.owner, current.owner) ||
135
+ (snapshot.rootBytes === null ? current.rootBytes !== null : current.rootBytes === null ||
136
+ !sameFileIdentity(snapshot.stat, current.stat) || !snapshot.rootBytes.equals(current.rootBytes))) {
137
+ throw new Error('session replay root changed before publication');
138
+ }
139
+ if (snapshot.stat !== null) {
140
+ const latest = await fs.lstat(path); privateStat(latest, false);
141
+ if (!unchanged(snapshot.stat, latest)) throw new Error('session replay root changed before publication');
142
+ }
143
+ };
144
+ const verifyChain = async (snapshot, visit) => {
145
+ let ref = snapshot.root?.head ?? null;
146
+ let total = snapshot.root?.byteLength ?? 0;
147
+ const seen = new Set();
148
+ while (ref !== null) {
149
+ reference(ref);
150
+ if (seen.has(ref.sha256)) throw new Error('session replay chain contains a cycle');
151
+ seen.add(ref.sha256);
152
+ const node = await chunkStore.readJson(ref);
153
+ if (!exactKeys(node, ['previous', 'content', 'byteLength', 'totalBytes']) ||
154
+ !count(node.byteLength) || node.byteLength === 0 || !count(node.totalBytes) ||
155
+ node.totalBytes !== total || node.byteLength > total) invalid();
156
+ reference(node.content);
157
+ if (node.previous !== null) reference(node.previous);
158
+ const bytes = await chunkStore.readBytes(node.content);
159
+ if (bytes.byteLength !== node.byteLength) throw new Error('session replay append length mismatch');
160
+ await visit?.(ref, node, bytes);
161
+ total -= node.byteLength;
162
+ ref = node.previous;
163
+ }
164
+ if (total !== 0) throw new Error('session replay chain length mismatch');
165
+ };
166
+ const visitChain = async (snapshot, visit) => {
167
+ try { return await verifyChain(snapshot, visit); }
168
+ catch (error) { publicationProof = null; throw error; }
169
+ };
170
+ const publish = async (snapshot, root, exclusive) => {
171
+ const bytes = Buffer.from(`${JSON.stringify(root)}\n`);
172
+ const temporary = join(sessionsDir, `.${sessionId}.replay-${randomUUID()}.tmp`);
173
+ let handle;
174
+ let temporaryStat;
175
+ try {
176
+ await assertOwner();
177
+ await assertSnapshot(snapshot);
178
+ handle = await fs.open(temporary, 'wx', 0o600);
179
+ await handle.chmod(0o600);
180
+ temporaryStat = await handle.stat(); privateStat(temporaryStat, false);
181
+ await handle.writeFile(bytes); await handle.sync();
182
+ const after = await handle.stat(); privateStat(after, false);
183
+ const current = await fs.lstat(temporary); privateStat(current, false);
184
+ if (!sameFileIdentity(temporaryStat, after) || !sameFileIdentity(after, current)) throw new Error('session replay temporary root changed');
185
+ await handle.close(); handle = undefined;
186
+ await assertOwner();
187
+ await assertSnapshot(snapshot);
188
+ if (exclusive || snapshot.root === null) await fs.link(temporary, path);
189
+ else await fs.rename(temporary, path);
190
+ // Exclusive publication briefly has two private links; remove the staging
191
+ // name before readers' single-link verification and directory syncing.
192
+ if (exclusive || snapshot.root === null) await fs.unlink(temporary);
193
+ await syncDirectory(snapshot.owner);
194
+ const published = await fs.lstat(path); privateStat(published, false);
195
+ if (!sameFileIdentity(temporaryStat, published) || published.size !== bytes.length) throw new Error('session replay root changed after publication');
196
+ const completed = { owner: snapshot.owner, stat: published, rootBytes: bytes };
197
+ mutationAnchor = proofOf(completed);
198
+ rememberProof(completed);
199
+ return { byteLength: root.byteLength };
200
+ } finally {
201
+ await handle?.close();
202
+ try {
203
+ const current = await fs.lstat(temporary);
204
+ if (temporaryStat && sameFileIdentity(temporaryStat, current)) await fs.unlink(temporary);
205
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
206
+ }
207
+ };
208
+ const append = async (input, exclusive) => {
209
+ if (!(input instanceof Uint8Array)) throw new TypeError('session replay bytes must be a Uint8Array');
210
+ const bytes = input;
211
+ await assertOwner();
212
+ const snapshot = await readRoot();
213
+ if (exclusive && snapshot.root !== null) throw new Error('session replay root already exists');
214
+ assertMutationAnchor(snapshot);
215
+ if (!exclusive && bytes.length === 0 && snapshot.root !== null) return { byteLength: snapshot.root.byteLength };
216
+ const prior = snapshot.root?.byteLength ?? 0;
217
+ const byteLength = prior + bytes.length;
218
+ if (!count(byteLength)) throw new Error('session replay byte count exceeds safe integer range');
219
+ // An append may extend only a fully verified predecessor. Subsequent
220
+ // appends can reuse that proof under the exclusive writer's immutable
221
+ // chunk rule; a changed root or directory requires verification again.
222
+ if (snapshot.root !== null && !matchesProof(snapshot)) {
223
+ await visitChain(snapshot);
224
+ rememberProof(snapshot);
225
+ }
226
+ const head = bytes.length ? await chunkStore.writeBytesAndJson(bytes, (content) => ({ previous: snapshot.root?.head ?? null, content, byteLength: bytes.length, totalBytes: byteLength })) : null;
227
+ return publish(snapshot, { kind: 'captain-session-replay', schemaVersion: 1, sessionId, head, byteLength }, exclusive);
228
+ };
229
+ const readSnapshot = async () => {
230
+ const snapshot = await readRoot();
231
+ const parts = [];
232
+ await visitChain(snapshot, (_ref, _node, bytes) => parts.push(bytes));
233
+ rememberProof(snapshot);
234
+ return { root: snapshot.root, rootBytes: snapshot.rootBytes, bytes: Buffer.concat(parts.reverse(), snapshot.root?.byteLength ?? 0) };
235
+ };
236
+ const admit = (bytes, exclusive) => {
237
+ if (!(bytes instanceof Uint8Array)) return Promise.reject(new TypeError('session replay bytes must be a Uint8Array'));
238
+ const admitted = Buffer.from(bytes);
239
+ return serialize(() => append(admitted, exclusive));
240
+ };
241
+ return Object.freeze({
242
+ appendBytes: (bytes) => admit(bytes, false),
243
+ initializeBytes: (bytes) => admit(bytes, true),
244
+ readSnapshot,
245
+ async readBytes() { return (await readSnapshot()).bytes; },
246
+ async rootBytes() { return (await readRoot()).rootBytes; },
247
+ checkpoint: ({ trustPublished = false } = {}) => serialize(async () => {
248
+ await assertOwner();
249
+ const snapshot = await readRoot();
250
+ assertMutationAnchor(snapshot);
251
+ if (!trustPublished || !matchesProof(snapshot)) {
252
+ await visitChain(snapshot);
253
+ rememberProof(snapshot);
254
+ }
255
+ await assertOwner();
256
+ await assertSnapshot(snapshot);
257
+ await syncDirectory(snapshot.owner);
258
+ return { byteLength: snapshot.root?.byteLength ?? 0 };
259
+ }),
260
+ async collectFiles() {
261
+ const snapshot = await readRoot();
262
+ if (snapshot.root === null) return [];
263
+ const files = new Map([[`${sessionId}.replay.json`, { path: `${sessionId}.replay.json`, byteLength: snapshot.rootBytes.length }]]);
264
+ await visitChain(snapshot, async (ref, node) => {
265
+ for (const pointer of [ref, node.content]) for (const file of await chunkStore.walk(pointer)) {
266
+ const relative = `${sessionId}.chunks/${file.path}`;
267
+ const existing = files.get(relative);
268
+ if (existing && existing.byteLength !== file.byteLength) invalid();
269
+ files.set(relative, { path: relative, byteLength: file.byteLength });
270
+ }
271
+ });
272
+ return [...files.values()];
273
+ },
274
+ deleteRoot: () => serialize(async () => {
275
+ await assertOwner();
276
+ const snapshot = await readRoot();
277
+ if (snapshot.root === null) return;
278
+ await assertOwner();
279
+ await assertSnapshot(snapshot);
280
+ await fs.unlink(path);
281
+ await syncDirectory(snapshot.owner);
282
+ publicationProof = null;
283
+ mutationAnchor = null;
284
+ }),
285
+ async prepare() {
286
+ await tightenPrivateEntry(sessionsDir, await fs.lstat(sessionsDir), true, fs, 'session replay directory');
287
+ try { await tightenPrivateEntry(path, await fs.lstat(path), false, fs, 'session replay root'); }
288
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
289
+ },
290
+ });
291
+ }