@jini-ai/vibecoding 0.6.10 → 0.6.11

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,478 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { withFileLock, writeFileAtomicAsync, writeJsonFileAtomicAsync } from '@jini-ai/platform/fs';
5
+ import { ProjectError } from '../core/project.js';
6
+ import { assertCandidatePaths, portablePathKey } from './paths.js';
7
+ function fingerprint(info) {
8
+ return { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs };
9
+ }
10
+ function sameFingerprint(left, right) {
11
+ return left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
12
+ }
13
+ /** Parents of removed managed files, deepest paths pruned first by callers. */
14
+ function removedFolders({ before, after }) {
15
+ const kept = new Set(after.files.map(file => file.path)), folders = new Set();
16
+ for (const file of before.files) {
17
+ if (kept.has(file.path))
18
+ continue;
19
+ for (let folder = dirname(file.path); folder !== '.'; folder = dirname(folder))
20
+ folders.add(folder);
21
+ }
22
+ return folders;
23
+ }
24
+ const HASH = /^[a-f0-9]{64}$/;
25
+ /** Immutable object/revision IDs are SHA-256 of their exact bytes. */
26
+ export function digest({ bytes }, _options = {}) {
27
+ return createHash('sha256').update(bytes).digest('hex');
28
+ }
29
+ function inside(base, target) {
30
+ const child = relative(base, target);
31
+ return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
32
+ }
33
+ function corrupt(message) { throw new ProjectError({ code: 'CORRUPT', message }); }
34
+ function assertHash(hash) { if (!HASH.test(hash))
35
+ corrupt('invalid content identifier'); }
36
+ function missing(error) { return error?.code === 'ENOENT'; }
37
+ /** Absent destinations (or a file where a parent folder was expected) are not errors here. */
38
+ async function lstatIfPresent(path) {
39
+ return fs.lstat(path).catch(error => { if (missing(error) || error.code === 'ENOTDIR')
40
+ return undefined; throw error; });
41
+ }
42
+ async function statIfPresent(path) {
43
+ return fs.stat(path).catch(error => { if (missing(error))
44
+ return undefined; throw error; });
45
+ }
46
+ /** Reject links and special files even when platform containment permits an in-root alias.
47
+ * Host owns the directory; this recheck is not a sandbox against hostile local processes.
48
+ * @complexity O(depth²) bounded containment probes and O(path length) space.
49
+ */
50
+ async function regularPath({ paths, path }) {
51
+ const absolute = paths.reader.resolve({ relativePath: path });
52
+ const segments = path.split('/');
53
+ for (let end = 1; end <= segments.length; end += 1) {
54
+ const probe = paths.reader.resolve({ relativePath: segments.slice(0, end).join('/') });
55
+ const info = await lstatIfPresent(probe);
56
+ if (!info)
57
+ continue;
58
+ if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
59
+ throw new ProjectError({ code: 'INVALID_PATH', message: `link or special file refused: ${path}` });
60
+ }
61
+ }
62
+ return absolute;
63
+ }
64
+ /** Bounded metadata read; malformed JSON is corruption, never a cue to reset a project. */
65
+ async function readJson({ path, maxBytes }) {
66
+ const handle = await fs.open(path, 'r');
67
+ try {
68
+ const bytes = await readBounded({ handle, maxBytes });
69
+ if (bytes.byteLength > maxBytes)
70
+ corrupt('metadata exceeds project limits');
71
+ try {
72
+ return JSON.parse(bytes.toString('utf8'));
73
+ }
74
+ catch (cause) {
75
+ throw new ProjectError({ code: 'CORRUPT', message: 'invalid project metadata JSON', cause });
76
+ }
77
+ }
78
+ finally {
79
+ await handle.close();
80
+ }
81
+ }
82
+ /** Read at most maxBytes + 1 so an oversized file is detected without an unbounded buffer. */
83
+ async function readBounded({ handle, maxBytes }) {
84
+ const bytes = Buffer.alloc(maxBytes + 1);
85
+ let count = 0;
86
+ while (count < bytes.length) {
87
+ const result = await handle.read(bytes, count, bytes.length - count, null);
88
+ if (!result.bytesRead)
89
+ break;
90
+ count += result.bytesRead;
91
+ }
92
+ return bytes.subarray(0, count);
93
+ }
94
+ /** Validate manifest structure, portable names, policy and aggregate bounds before any I/O. */
95
+ function checkManifest({ value, paths, limits }) {
96
+ const manifest = value;
97
+ if (manifest?.version !== 1 || !Array.isArray(manifest.files))
98
+ corrupt('unsupported manifest');
99
+ if (manifest.files.length > limits.maxFiles)
100
+ corrupt('manifest exceeds file limit');
101
+ let bytes = 0;
102
+ const seen = new Set();
103
+ for (const file of manifest.files) {
104
+ checkManifestFile({ file, paths, limits });
105
+ if (seen.has(file.path))
106
+ corrupt('duplicate manifest path');
107
+ seen.add(file.path);
108
+ bytes += file.size;
109
+ }
110
+ if (bytes > limits.maxTreeBytes)
111
+ corrupt('manifest exceeds tree limit');
112
+ assertCandidatePaths({ paths: [...seen] });
113
+ return manifest;
114
+ }
115
+ function checkManifestFile({ file, paths, limits }) {
116
+ if (!file || typeof file.path !== 'string' || typeof file.hash !== 'string')
117
+ corrupt('invalid manifest file');
118
+ assertHash(file.hash);
119
+ paths.check({ relativePath: file.path });
120
+ if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > limits.maxFileBytes)
121
+ corrupt('invalid manifest size');
122
+ }
123
+ function checkCurrent({ value, root }) {
124
+ const current = value;
125
+ if (current?.version !== 1 || current.root !== root)
126
+ corrupt('stateDir belongs to another root or schema');
127
+ assertHash(current.revision);
128
+ if (!Number.isSafeInteger(current.generation) || current.generation < 1)
129
+ corrupt('invalid generation');
130
+ checkPending(current.pending);
131
+ checkObserved(current.observed);
132
+ return current;
133
+ }
134
+ function checkObserved(observed) {
135
+ if (!observed || typeof observed !== 'object' || Array.isArray(observed))
136
+ corrupt('invalid observed file metadata');
137
+ for (const value of Object.values(observed))
138
+ checkFingerprint(value);
139
+ }
140
+ function checkFingerprint(value) {
141
+ if (!value || typeof value !== 'object')
142
+ corrupt('invalid file fingerprint');
143
+ if (!Number.isSafeInteger(value.size) || value.size < 0)
144
+ corrupt('invalid fingerprint size');
145
+ if (!Number.isFinite(value.mtimeMs) || !Number.isFinite(value.ctimeMs))
146
+ corrupt('invalid fingerprint timestamps');
147
+ }
148
+ function checkPending(pending) {
149
+ if (pending === null)
150
+ return;
151
+ if (typeof pending !== 'object' || !pending || !('from' in pending))
152
+ corrupt('invalid pending journal');
153
+ if (pending.from !== null)
154
+ assertHash(pending.from);
155
+ }
156
+ /** Host state is physically separate from the watched/served tree, in both directions.
157
+ * @throws ProjectError INVALID_PATH when either canonical directory contains the other.
158
+ * @complexity O(path length) plus constant directory I/O.
159
+ */
160
+ export async function prepareDirectories({ root, stateDir }, _options = {}) {
161
+ await fs.mkdir(root, { recursive: true });
162
+ await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
163
+ const canonicalRoot = await fs.realpath(root), canonicalState = await fs.realpath(stateDir);
164
+ if (inside(canonicalRoot, canonicalState) || inside(canonicalState, canonicalRoot)) {
165
+ throw new ProjectError({ code: 'INVALID_PATH', message: 'stateDir must be outside and separate from the app root' });
166
+ }
167
+ return { root: canonicalRoot, stateDir: canonicalState };
168
+ }
169
+ /** Content-addressed persistence and idempotent roll-forward. Call mutations inside locked().
170
+ * @complexity O(bytes + files log files + files × depth²) for capture; memory bounded by maxTreeBytes.
171
+ */
172
+ export function createProjectStore({ root, stateDir, paths, limits }, options = {}) {
173
+ const currentPath = resolve(stateDir, 'current.json');
174
+ const objectPath = (hash) => { assertHash(hash); return resolve(stateDir, 'objects', hash); };
175
+ const manifestPath = (hash) => { assertHash(hash); return resolve(stateDir, 'manifests', `${hash}.json`); };
176
+ const checkpoint = async (step) => { await options.checkpoint?.({ step }); };
177
+ const metadataLimit = limits.maxFiles * 1024 + 4096;
178
+ async function readCurrent() {
179
+ const value = await readJson({ path: currentPath, maxBytes: metadataLimit }).catch(error => {
180
+ if (missing(error))
181
+ return undefined;
182
+ throw error;
183
+ });
184
+ return value === undefined ? null : checkCurrent({ value, root });
185
+ }
186
+ async function manifest({ revisionId }) {
187
+ const value = await readJson({ path: manifestPath(revisionId), maxBytes: metadataLimit });
188
+ const result = checkManifest({ value, paths, limits });
189
+ if (digest({ bytes: JSON.stringify(result) }) !== revisionId)
190
+ corrupt('manifest digest mismatch');
191
+ return result;
192
+ }
193
+ async function object({ file }) {
194
+ const handle = await fs.open(objectPath(file.hash), 'r').catch(cause => {
195
+ if (missing(cause))
196
+ throw new ProjectError({ code: 'CORRUPT', message: 'referenced object is missing', cause });
197
+ throw cause;
198
+ });
199
+ try {
200
+ const content = await readBounded({ handle, maxBytes: file.size });
201
+ if (content.byteLength !== file.size || digest({ bytes: content }) !== file.hash)
202
+ corrupt('object digest/size mismatch');
203
+ return content;
204
+ }
205
+ finally {
206
+ await handle.close();
207
+ }
208
+ }
209
+ /** Prepare every durable dependency before publishing a pending selection. */
210
+ async function prepare({ files }) {
211
+ assertCandidatePaths({ paths: [...files.keys()] });
212
+ const entries = [];
213
+ for (const [path, bytes] of [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
214
+ const file = { path, hash: digest({ bytes }), size: bytes.byteLength };
215
+ entries.push(file);
216
+ }
217
+ const value = checkManifest({ value: { version: 1, files: entries }, paths, limits });
218
+ for (const file of entries)
219
+ await writeObject({ file, bytes: files.get(file.path) });
220
+ await checkpoint('objects');
221
+ const revisionId = digest({ bytes: JSON.stringify(value) });
222
+ const existing = await statIfPresent(manifestPath(revisionId));
223
+ if (existing)
224
+ await manifest({ revisionId });
225
+ else
226
+ await writeJsonFileAtomicAsync({ filePath: manifestPath(revisionId), data: value }, { createParent: true });
227
+ await checkpoint('manifest');
228
+ return revisionId;
229
+ }
230
+ async function writeObject({ file, bytes }) {
231
+ const exists = await statIfPresent(objectPath(file.hash));
232
+ if (exists)
233
+ await object({ file });
234
+ else
235
+ await writeFileAtomicAsync({ filePath: objectPath(file.hash), content: bytes }, { createParent: true });
236
+ }
237
+ async function observations({ files }) {
238
+ const result = Object.create(null);
239
+ for (const file of files) {
240
+ const absolute = await regularPath({ paths, path: file.path });
241
+ const info = await fs.stat(absolute);
242
+ const bytes = await paths.reader.readBytes({ relativePath: file.path, maxBytes: limits.maxFileBytes });
243
+ const after = await fs.stat(absolute);
244
+ // A watcher may rewrite a file immediately after a rename. Do not cache its new timestamp
245
+ // against old object bytes: leaving it unobserved makes the next capture hash the drift.
246
+ if (sameFingerprint(info, after) && digest({ bytes }) === file.hash)
247
+ result[file.path] = fingerprint(info);
248
+ }
249
+ return result;
250
+ }
251
+ /** Capture hashes only files whose disk fingerprint changed; unchanged bytes reuse objects. */
252
+ async function scan({ current }) {
253
+ const listed = paths.reader.list({});
254
+ if (listed.truncated)
255
+ throw new ProjectError({ code: 'LIMIT', message: 'tree traversal exceeded limits' });
256
+ // Host-disallowed files remain unmanaged; malformed allowed names are refused rather than hidden.
257
+ const names = listed.files.filter(relativePath => paths.allows({ relativePath }));
258
+ assertCandidatePaths({ paths: names });
259
+ const base = current ? await manifest({ revisionId: current.revision }) : { files: [] };
260
+ const previous = new Map(base.files.map(file => [file.path, file]));
261
+ const files = new Map();
262
+ let total = 0;
263
+ const observed = Object.create(null);
264
+ for (const path of names) {
265
+ const scanned = await scanFile({ path, previous: previous.get(path), observed: current?.observed[path] });
266
+ total += scanned.bytes.byteLength;
267
+ if (total > limits.maxTreeBytes || files.size >= limits.maxFiles)
268
+ throw new ProjectError({ code: 'LIMIT', message: 'tree exceeds project limits' });
269
+ files.set(path, scanned.bytes);
270
+ observed[path] = scanned.observed;
271
+ }
272
+ return { files, observed };
273
+ }
274
+ async function scanFile({ path, previous, observed }) {
275
+ paths.check({ relativePath: path });
276
+ const absolute = await regularPath({ paths, path });
277
+ const info = await fs.stat(absolute);
278
+ const bytes = previous && observed && sameFingerprint(info, observed)
279
+ ? await object({ file: previous })
280
+ : await paths.reader.readBytes({ relativePath: path, maxBytes: limits.maxFileBytes });
281
+ const after = await fs.stat(absolute);
282
+ if (!sameFingerprint(info, after))
283
+ throw new ProjectError({ code: 'CONFLICT', message: `file changed while capturing: ${path}` });
284
+ return { bytes, observed: fingerprint(info) };
285
+ }
286
+ /** Read a published live file without scanning/re-hashing the whole project for every viewer read. */
287
+ async function readWorkingFile({ relativePath }) {
288
+ await regularPath({ paths, path: relativePath });
289
+ return paths.reader.readBytes({ relativePath, maxBytes: limits.maxFileBytes });
290
+ }
291
+ async function removeManaged({ before, after }) {
292
+ const retained = new Set(after.files.map(file => file.path));
293
+ for (const file of before.files) {
294
+ if (retained.has(file.path))
295
+ continue;
296
+ try {
297
+ await fs.rm(await regularPath({ paths, path: file.path }), { force: true });
298
+ }
299
+ catch (error) {
300
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code ?? ''))
301
+ throw error;
302
+ }
303
+ await checkpoint('delete');
304
+ }
305
+ }
306
+ /** Prune only directories that contained a removed managed file; never recurse into exclusions. */
307
+ async function prune({ before, after }) {
308
+ const folders = removedFolders({ before, after });
309
+ for (const folder of [...folders].sort((a, b) => b.split('/').length - a.split('/').length)) {
310
+ await pruneFolder({ folder });
311
+ }
312
+ await checkpoint('prune');
313
+ }
314
+ async function pruneFolder({ folder }) {
315
+ try {
316
+ await fs.rmdir(await regularPath({ paths, path: folder }));
317
+ }
318
+ catch (error) {
319
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'ENOTDIR'].includes(error.code ?? ''))
320
+ throw error;
321
+ }
322
+ }
323
+ /** On case-insensitive volumes, reusing an empty old directory preserves its old spelling.
324
+ * Remove only empty managed aliases before writes, so a case-only directory rename is real.
325
+ */
326
+ async function pruneRenamedFolders({ before, after }) {
327
+ const previous = removedFolders({ before, after: { version: 1, files: [] } });
328
+ const next = removedFolders({ before: after, after: { version: 1, files: [] } });
329
+ const aliases = new Map([...next].map(folder => [portablePathKey({ path: folder }), folder]));
330
+ for (const folder of [...previous].sort((a, b) => b.length - a.length)) {
331
+ const alias = aliases.get(portablePathKey({ path: folder }));
332
+ if (alias !== undefined && alias !== folder)
333
+ await pruneFolder({ folder });
334
+ }
335
+ }
336
+ /** Pre-read/verify every object before the first deletion. Pending remains until all writes land. */
337
+ async function materialize({ current }) {
338
+ const after = await manifest({ revisionId: current.revision });
339
+ const before = current.pending?.from ? await manifest({ revisionId: current.pending.from }) : { version: 1, files: [] };
340
+ const previous = new Map(before.files.map(file => [file.path, file.hash]));
341
+ const writes = after.files.filter(file => previous.get(file.path) !== file.hash);
342
+ const bytes = new Map();
343
+ for (const file of after.files)
344
+ bytes.set(file.path, await object({ file }));
345
+ await removeManaged({ before, after });
346
+ await pruneRenamedFolders({ before, after });
347
+ // Empty folders blocking a directory-to-file transition must be removed before writing it.
348
+ for (const file of writes)
349
+ await removeBlockingFolder({ path: file.path, before });
350
+ for (const file of writes) {
351
+ await writeFileAtomicAsync({ filePath: await regularPath({ paths, path: file.path }), content: bytes.get(file.path) }, { createParent: true, refuseSymlink: true, defaultMode: 0o644 });
352
+ await checkpoint('write');
353
+ }
354
+ await prune({ before, after });
355
+ const settled = { ...current, pending: null, observed: await observations(after) };
356
+ await writeJsonFileAtomicAsync({ filePath: currentPath, data: settled });
357
+ await checkpoint('clear');
358
+ return settled;
359
+ }
360
+ async function removeBlockingFolder({ path, before }) {
361
+ const destination = await regularPath({ paths, path });
362
+ const info = await lstatIfPresent(destination);
363
+ if (!info?.isDirectory())
364
+ return;
365
+ const folders = removedFolders({ before, after: { version: 1, files: [] } });
366
+ for (const folder of [...folders].sort((a, b) => b.length - a.length)) {
367
+ if (folder.startsWith(`${path}/`))
368
+ await pruneFolder({ folder });
369
+ }
370
+ await fs.rmdir(destination);
371
+ }
372
+ /** Refuse unsafe/unmanaged obstructions before the journal commit point. */
373
+ async function preflight({ current, revisionId }) {
374
+ const after = await manifest({ revisionId });
375
+ const before = current ? await manifest({ revisionId: current.revision }) : { version: 1, files: [] };
376
+ const folders = removedFolders({ before, after });
377
+ const kept = new Set(after.files.map(file => file.path));
378
+ const removed = new Set(before.files.filter(file => !kept.has(file.path)).map(file => file.path));
379
+ const sources = new Map(before.files.map(file => [portablePathKey({ path: file.path }), file.path]));
380
+ await assertRenamedFolders({ before, after, folders, removed });
381
+ for (const file of after.files) {
382
+ await object({ file });
383
+ const target = await regularPath({ paths, path: file.path });
384
+ const info = await lstatIfPresent(target);
385
+ assertManagedDestination({ existsAsFile: info?.isFile() ?? false, path: file.path, sources });
386
+ if (info?.isDirectory())
387
+ await assertRemovableFolder({ path: file.path, folders, removed });
388
+ }
389
+ }
390
+ /** An existing filesystem alias must not let a create overwrite a host-disallowed file. */
391
+ function assertManagedDestination({ existsAsFile, path, sources }) {
392
+ if (!existsAsFile || sources.has(portablePathKey({ path })))
393
+ return;
394
+ throw new ProjectError({ code: 'INVALID_CHANGE', message: `unmanaged file blocks ${path}` });
395
+ }
396
+ /** A portable case rename cannot silently retain an unmanaged directory under its old spelling. */
397
+ async function assertRenamedFolders({ before, after, folders, removed }) {
398
+ const previous = removedFolders({ before, after: { version: 1, files: [] } });
399
+ const next = removedFolders({ before: after, after: { version: 1, files: [] } });
400
+ const aliases = new Map([...next].map(folder => [portablePathKey({ path: folder }), folder]));
401
+ for (const path of previous) {
402
+ const alias = aliases.get(portablePathKey({ path }));
403
+ if (alias !== undefined && alias !== path)
404
+ await assertRemovableFolder({ path, folders, removed });
405
+ }
406
+ }
407
+ async function assertRemovableFolder({ path, folders, removed }) {
408
+ const inspect = new Set([path, ...[...folders].filter(folder => folder.startsWith(`${path}/`))]);
409
+ for (const folder of inspect) {
410
+ for (const name of await fs.readdir(await regularPath({ paths, path: folder }))) {
411
+ const child = `${folder}/${name}`;
412
+ if (!folders.has(child) && !removed.has(child))
413
+ throw new ProjectError({ code: 'INVALID_CHANGE', message: `unmanaged directory contents block ${path}` });
414
+ }
415
+ }
416
+ }
417
+ async function recover() {
418
+ const current = await readCurrent();
419
+ if (!current)
420
+ return null;
421
+ try {
422
+ await manifest({ revisionId: current.revision });
423
+ }
424
+ catch (cause) {
425
+ if (missing(cause))
426
+ throw new ProjectError({ code: 'CORRUPT', message: 'current manifest is missing', cause });
427
+ throw cause;
428
+ }
429
+ return current.pending ? materialize({ current }) : current;
430
+ }
431
+ /** Journal is the commit point. Reopen rolls it forward; no rollback or guessed replacement. */
432
+ async function select({ current, revisionId, captured }) {
433
+ await manifest({ revisionId });
434
+ if (current?.generation === Number.MAX_SAFE_INTEGER)
435
+ throw new ProjectError({ code: 'LIMIT', message: 'generation exhausted' });
436
+ const next = { version: 1, root, revision: revisionId, generation: (current?.generation ?? 0) + 1,
437
+ pending: captured ? null : { from: current?.revision ?? null }, observed: captured ?? {} };
438
+ await writeJsonFileAtomicAsync({ filePath: currentPath, data: next });
439
+ await checkpoint('journal');
440
+ return captured ? next : materialize({ current: next });
441
+ }
442
+ /** Refresh a same-content capture's fingerprints without inventing a new revision/generation. */
443
+ async function observe({ current, observed }) {
444
+ if (JSON.stringify(current.observed) === JSON.stringify(observed))
445
+ return current;
446
+ const next = { ...current, observed };
447
+ await writeJsonFileAtomicAsync({ filePath: currentPath, data: next });
448
+ return next;
449
+ }
450
+ /** Both stacks pin manifests and blobs. Expired snapshots do not pin storage indefinitely. */
451
+ async function collect({ revisions }) {
452
+ const objects = new Set();
453
+ for (const revisionId of revisions) {
454
+ for (const file of (await manifest({ revisionId })).files)
455
+ objects.add(file.hash);
456
+ }
457
+ await removeUnreferenced({ directory: resolve(stateDir, 'manifests'), keep: new Set([...revisions].map(id => `${id}.json`)) });
458
+ await removeUnreferenced({ directory: resolve(stateDir, 'objects'), keep: objects });
459
+ }
460
+ return {
461
+ readCurrent, manifest, object, prepare, scan, select, collect, preflight, observe, readWorkingFile,
462
+ locked: (run) => withFileLock({ lockPath: resolve(stateDir, 'project.lock'), run }, { staleMs: Infinity, createParent: true }),
463
+ recover,
464
+ };
465
+ }
466
+ /** Generated orphan objects/manifests are safe to discard only while holding the project lock. */
467
+ async function removeUnreferenced({ directory, keep }) {
468
+ const names = await fs.readdir(directory).catch(error => { if (missing(error))
469
+ return []; throw error; });
470
+ for (const name of names) {
471
+ if (!keep.has(name))
472
+ await fs.rm(resolve(directory, name), { force: true });
473
+ }
474
+ }
475
+ export function revisionToken({ current }, _options = {}) {
476
+ return { revisionId: current.revision, generation: current.generation };
477
+ }
478
+ //# sourceMappingURL=store.js.map
@@ -1,108 +1,3 @@
1
- /**
2
- * @module react/session
3
- *
4
- * `VibecodingSession` — the one stateful object this package's React layer adds on top of
5
- * `../core`'s `EditTarget` + `EditHistory`. Everything else in `./react` (the hook, the tool
6
- * registrations, the presentational components) reads and drives an artifact through this seam
7
- * rather than touching `EditTarget`/`EditHistory` directly, for one reason: a tool call arrives
8
- * from the chat's execution path, not from a React event handler, so the thing tool handlers call
9
- * into cannot be React state — it has to be a plain, subscribable object a `useSyncExternalStore`
10
- * hook can also read. This is the same shape React itself recommends for external stores; nothing
11
- * here is React-specific, but it lives under `./react` because "chat + preview surface" is exactly
12
- * what it exists to support (see this package's README's `./react` row).
13
- *
14
- * ## Why part content is cached rather than re-read on every render
15
- *
16
- * `EditTarget.readPart` is a host call — for the `html` target it is cheap (a string slice), but
17
- * the port makes no such promise for every target (a filesystem or sandbox-backed host could hit a
18
- * network round trip per part). Re-reading every listed part's content on every `refresh()` would
19
- * turn "the operator opened the file viewer" into an O(part count) fan-out of host calls, and
20
- * nothing here can assume that count stays small — see `../core/target.ts`'s own doc on why
21
- * `listParts` is the allowlist, not a bound on its size. So `refresh()` only calls `listParts()`;
22
- * content is fetched lazily, per id, through `readPart()`, and cached. Every mutating operation
23
- * below (`applyEdits`, `undo`, `redo`, `restoreSnapshot`) already knows the exact before/after
24
- * content it just wrote — `../core/apply.ts`'s `ProposedEdit`, `../core/history.ts`'s
25
- * `PartChange.before`/`.after` — so the cache is updated from that data directly instead of
26
- * re-reading the part it just wrote back from the host.
27
- */
28
- import type { ApplyOutcome, PartId, PartRef, Snapshot } from '../core/types.js';
29
- import type { EditTarget } from '../core/target.js';
30
- import { type ProposedEdit } from '../core/apply.js';
31
- import { type EditHistory, type EditHistoryOptions, type HistoryEntry } from '../core/history.js';
32
- export interface VibecodingSessionArgs {
33
- /** The host's artifact port. See `../core/target.ts`. */
34
- readonly target: EditTarget;
35
- }
36
- export interface VibecodingSessionOptions {
37
- /** Forwarded to `createEditHistory`. See `../core/history.ts`'s `EditHistoryOptions`. */
38
- readonly historyOptions?: EditHistoryOptions;
39
- }
40
- /**
41
- * Immutable, point-in-time read of a session. `getSnapshot()` returns the same object reference
42
- * until something actually changes — required for `useSyncExternalStore`, which re-renders a
43
- * subscriber whenever the reference it reads changes, not whenever it is merely called again.
44
- */
45
- export interface VibecodingSessionSnapshot {
46
- /** `'idle'` until the first `refresh()` resolves; `'error'` reflects the most recent failure, not
47
- * a permanently wedged session — a subsequent successful call returns it to `'ready'`. */
48
- readonly status: 'idle' | 'loading' | 'ready' | 'error';
49
- /** Every part the host currently advertises, in `EditTarget.listParts()`'s own order. */
50
- readonly parts: readonly PartRef[];
51
- /** Cached content, keyed by part id. A part absent from this map has not been read yet — see
52
- * this module's doc for why content is lazy rather than eagerly fetched for every part. */
53
- readonly partContent: ReadonlyMap<PartId, string>;
54
- readonly canUndo: boolean;
55
- readonly canRedo: boolean;
56
- /** The most recent operation's failure message, if any. Cleared by the next successful call. */
57
- readonly lastError: string | undefined;
58
- }
59
- export interface ApplyEditsResult {
60
- readonly outcomes: readonly ApplyOutcome[];
61
- /** See `../core/apply.ts`'s `correctionsFor` — the subset a caller feeds back to the model. */
62
- readonly corrections: readonly {
63
- readonly id: PartId;
64
- readonly reason: string;
65
- }[];
66
- }
67
- export interface VibecodingSession {
68
- /** The underlying port, for a caller (or test) that needs it directly — e.g. to read the whole
69
- * document a host-specific target stores, which is outside this generic session's vocabulary. */
70
- readonly target: EditTarget;
71
- /** The underlying undo/redo stack, for a caller that needs `.entries()` for a history UI. */
72
- readonly history: EditHistory;
73
- getSnapshot(): VibecodingSessionSnapshot;
74
- /** Returns an unsubscribe function; the React hook adapts this object API for React. */
75
- subscribe(args: {
76
- readonly listener: () => void;
77
- }): () => void;
78
- /** Re-lists parts from the host. Does not re-read content — see this module's doc. */
79
- refresh(): Promise<void>;
80
- /** Cached content for `id`, fetching once from the host on first request. */
81
- readPart(args: {
82
- readonly id: PartId;
83
- }): Promise<string>;
84
- /** Applies `edits` as one undoable transaction and updates the cache from the edits themselves. */
85
- applyEdits(requiredArgs: {
86
- readonly edits: readonly ProposedEdit[];
87
- }, optionalArgs?: {
88
- readonly label?: string;
89
- }): Promise<ApplyEditsResult>;
90
- undo(): Promise<HistoryEntry | null>;
91
- redo(): Promise<HistoryEntry | null>;
92
- takeSnapshot(): Promise<Snapshot>;
93
- restoreSnapshot(args: {
94
- readonly snapshot: Snapshot;
95
- }): Promise<HistoryEntry | null>;
96
- }
97
- /**
98
- * Builds a session over the required `target` port, with its own private `EditHistory`.
99
- * History settings belong to the optional second object.
100
- *
101
- * The returned session starts in `'idle'` with no parts listed — call `refresh()` (or let
102
- * `useVibecodingSession` do it on mount) to populate it. Nothing here triggers I/O eagerly, so
103
- * constructing a session has no side effect a caller needs to clean up.
104
- *
105
- * @complexity Construction is O(1). See each method for its own cost.
106
- */
107
- export declare function createVibecodingSession({ target }: VibecodingSessionArgs, options?: VibecodingSessionOptions): VibecodingSession;
1
+ /** Compatibility re-export; the implementation is framework-free. */
2
+ export * from "../session.js";
108
3
  //# sourceMappingURL=session.d.ts.map