@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,367 @@
1
+ import { createEditHistory } from '../core/history.js';
2
+ import { FsFilePathError } from '@jini-ai/platform/fs';
3
+ import { ProjectError } from '../core/project.js';
4
+ import { assertCandidatePaths, createProjectPaths, projectLimits } from './paths.js';
5
+ import { createProjectStore, prepareDirectories, revisionToken } from './store.js';
6
+ const POINTER = 'revision';
7
+ function fail(code, message) {
8
+ throw new ProjectError({ code, message });
9
+ }
10
+ function sameRevision(left, right) {
11
+ return left.generation === right.generation && left.revisionId === right.revisionId;
12
+ }
13
+ /** Materialize a single immutable candidate in memory before invoking a whole-project validator.
14
+ * @complexity O(tree bytes + changes + path segments) time/space, all bounded by project limits.
15
+ */
16
+ function changeFiles({ files: base, changes, paths, limits }) {
17
+ if (changes.length > limits.maxChanges)
18
+ fail('LIMIT', 'too many changes in one turn');
19
+ const files = new Map(base), seen = new Set();
20
+ for (const change of changes) {
21
+ const path = paths.check({ relativePath: change.op === 'create' ? change.relativePath : change.id });
22
+ if (seen.has(path))
23
+ fail('INVALID_CHANGE', `multiple operations for ${path}`);
24
+ seen.add(path);
25
+ changeFile({ files, change, path, limits });
26
+ }
27
+ assertCandidatePaths({ paths: [...files.keys()] });
28
+ assertTreeLimits({ files, limits });
29
+ return files;
30
+ }
31
+ function changeFile({ files, change, path, limits }) {
32
+ if (change.op === 'create') {
33
+ if (files.has(path))
34
+ fail('INVALID_CHANGE', `file already exists: ${path}`);
35
+ }
36
+ else if (!files.has(path))
37
+ fail('INVALID_CHANGE', `file not listed: ${path}`);
38
+ if (change.op === 'delete') {
39
+ files.delete(path);
40
+ return;
41
+ }
42
+ if (change.content.length > limits.maxFileBytes)
43
+ fail('LIMIT', `file exceeds byte limit: ${path}`);
44
+ const bytes = new TextEncoder().encode(change.content);
45
+ if (bytes.byteLength > limits.maxFileBytes)
46
+ fail('LIMIT', `file exceeds byte limit: ${path}`);
47
+ files.set(path, bytes);
48
+ }
49
+ function assertTreeLimits({ files, limits }) {
50
+ if (files.size > limits.maxFiles)
51
+ fail('LIMIT', 'candidate exceeds file count');
52
+ let total = 0;
53
+ for (const bytes of files.values())
54
+ total += bytes.byteLength;
55
+ if (total > limits.maxTreeBytes)
56
+ fail('LIMIT', 'candidate exceeds tree byte limit');
57
+ }
58
+ function candidateView({ files }) {
59
+ return Object.freeze({
60
+ paths: Object.freeze([...files.keys()].sort()),
61
+ async readBytes({ relativePath }, _options = {}) {
62
+ const bytes = files.get(relativePath);
63
+ if (!bytes)
64
+ fail('INVALID_PATH', `file not in candidate: ${relativePath}`);
65
+ return Uint8Array.from(bytes);
66
+ },
67
+ });
68
+ }
69
+ /** Bound host diagnostics before retaining or sending them back to a model. */
70
+ function diagnosticsFor(input = []) {
71
+ return input.slice(0, 100).map(item => ({ code: item.code.slice(0, 100), message: item.message.slice(0, 2000),
72
+ ...(item.relativePath === undefined ? {} : { relativePath: item.relativePath.slice(0, 200) }) }));
73
+ }
74
+ /** Timeout/cancellation races the validator, even when an adapter ignores its AbortSignal.
75
+ * @complexity O(candidate bytes) only in the adapter; this wrapper uses O(1) resources.
76
+ */
77
+ async function validateCandidate({ validator, candidate, signal, timeoutMs }) {
78
+ if (signal?.aborted)
79
+ fail('CANCELLED', 'turn cancelled');
80
+ if (!validator)
81
+ return [];
82
+ const controller = new AbortController();
83
+ const cancel = () => controller.abort(new ProjectError({ code: 'CANCELLED', message: 'turn cancelled' }));
84
+ signal?.addEventListener('abort', cancel, { once: true });
85
+ const timer = setTimeout(() => controller.abort(new ProjectError({ code: 'VALIDATION', message: 'validation timed out' })), timeoutMs);
86
+ let onAbort = () => { };
87
+ const aborted = new Promise((_resolve, reject) => {
88
+ onAbort = () => reject(controller.signal.reason);
89
+ controller.signal.addEventListener('abort', onAbort, { once: true });
90
+ });
91
+ try {
92
+ const result = await Promise.race([validator.validate({ candidate, signal: controller.signal }), aborted]);
93
+ if (result.ok)
94
+ return [];
95
+ const diagnostics = diagnosticsFor(result.diagnostics);
96
+ return diagnostics.length ? diagnostics : [{ code: 'VALIDATION', message: 'candidate refused by validator' }];
97
+ }
98
+ catch (cause) {
99
+ if (cause instanceof ProjectError)
100
+ throw cause;
101
+ throw new ProjectError({ code: 'VALIDATION', message: 'project validator failed', cause });
102
+ }
103
+ finally {
104
+ clearTimeout(timer);
105
+ signal?.removeEventListener('abort', cancel);
106
+ controller.signal.removeEventListener('abort', onAbort);
107
+ }
108
+ }
109
+ /** Open real local files with process-crash roll-forward and session-local pointer history.
110
+ * @throws ProjectError for conflicts, unsafe paths, corruption, limits or I/O failure.
111
+ * @complexity O(files + bytes) opening/capture; each turn is bounded by configured tree limits.
112
+ */
113
+ export async function openFileTreeProject(input, options = {}) {
114
+ const limits = projectLimits(options);
115
+ const historyLimit = options.historyLimit ?? 50;
116
+ if (!Number.isSafeInteger(historyLimit) || historyLimit <= 0)
117
+ throw new TypeError('historyLimit must be positive');
118
+ try {
119
+ const directories = await prepareDirectories(input);
120
+ const paths = createProjectPaths({ root: directories.root, policy: input.policy, limits });
121
+ const store = createProjectStore({ ...directories, paths, limits });
122
+ const project = assembleProject({ store, paths, limits, historyLimit, validator: input.validator });
123
+ await project.capture();
124
+ return project;
125
+ }
126
+ catch (cause) {
127
+ if (cause instanceof ProjectError || cause instanceof TypeError)
128
+ throw cause;
129
+ throw new ProjectError({ code: 'STORAGE', message: 'could not open local project', cause });
130
+ }
131
+ }
132
+ /** One process-local queue plus the shared file lock covers all mutation paths.
133
+ * Validation releases the shared lock, then checks generation AND working-tree drift again.
134
+ */
135
+ function assembleProject({ store, paths, limits, historyLimit, validator }) {
136
+ let current = null, closed = false;
137
+ let selectingCapture;
138
+ let queue = Promise.resolve();
139
+ const pointer = {
140
+ listParts: async () => [{ id: POINTER }],
141
+ readPart: async () => selected().revision,
142
+ replacePart: async ({ id, content }) => {
143
+ if (id !== POINTER)
144
+ fail('INVALID_CHANGE', 'private revision pointer only');
145
+ if (!selectingCapture)
146
+ await store.preflight({ current, revisionId: content });
147
+ current = await store.select({ current: selected(), revisionId: content, captured: selectingCapture });
148
+ },
149
+ validate: async () => ({ ok: true }),
150
+ snapshot: async () => ({ id: selected().revision, parts: { [POINTER]: selected().revision } }),
151
+ restore: async () => { fail('INVALID_CHANGE', 'use project.restore'); },
152
+ };
153
+ const rawHistory = createEditHistory({ target: pointer }, { limit: historyLimit });
154
+ function selected() {
155
+ if (!current)
156
+ fail('CORRUPT', 'project has no current revision');
157
+ return current;
158
+ }
159
+ function serial(work) {
160
+ const next = queue.then(async () => {
161
+ if (closed)
162
+ fail('CLOSED', 'project is closed');
163
+ return work();
164
+ });
165
+ queue = next.catch(() => { });
166
+ return next;
167
+ }
168
+ /** Never replay a local stack over a different writer's generation, even after A→B→A. */
169
+ async function synchronize() {
170
+ const disk = await store.recover();
171
+ if (current && (!disk || !sameRevision(revisionToken({ current }), revisionToken({ current: disk })))) {
172
+ current = disk;
173
+ rawHistory.clear();
174
+ fail('CONFLICT', 'project generation changed; local history cleared');
175
+ }
176
+ current = disk;
177
+ }
178
+ async function locked(work) {
179
+ try {
180
+ return await store.locked(async () => { await synchronize(); return work(); });
181
+ }
182
+ catch (cause) {
183
+ // Only synchronize() clears history: another writer's generation makes the stacks unsound.
184
+ // A stale caller token or a file written mid-scan leaves this handle's stacks coherent.
185
+ if (cause instanceof ProjectError)
186
+ throw cause;
187
+ if (cause instanceof FsFilePathError)
188
+ throw new ProjectError({ code: 'INVALID_PATH', message: cause.message, cause });
189
+ throw new ProjectError({ code: 'STORAGE', message: 'project storage operation failed', cause });
190
+ }
191
+ }
192
+ /** The pointer write is the only and last write inside history's recording callback. */
193
+ async function record({ revisionId, captured, label }) {
194
+ if (!captured)
195
+ await store.preflight({ current, revisionId });
196
+ selectingCapture = captured;
197
+ try {
198
+ await rawHistory.transaction({ work: recording => recording.replacePart({ id: POINTER, content: revisionId }) }, label === undefined ? {} : { label });
199
+ }
200
+ finally {
201
+ selectingCapture = undefined;
202
+ }
203
+ }
204
+ async function collect() {
205
+ const revisions = new Set([selected().revision]);
206
+ for (const entry of rawHistory.retainedEntries()) {
207
+ for (const change of entry.changes) {
208
+ revisions.add(change.before);
209
+ revisions.add(change.after);
210
+ }
211
+ }
212
+ await store.collect({ revisions });
213
+ }
214
+ /** Drift becomes a normal revision without overwriting what npm/editor/user just wrote. */
215
+ async function captureLocked() {
216
+ const { files, observed } = await store.scan({ current });
217
+ const revisionId = await store.prepare({ files });
218
+ const previous = current?.revision;
219
+ if (!current)
220
+ current = await store.select({ current: null, revisionId, captured: observed });
221
+ else if (current.revision !== revisionId)
222
+ await record({ revisionId, captured: observed, label: 'Capture external changes' });
223
+ else
224
+ current = await store.observe({ current, observed });
225
+ if (previous !== revisionId)
226
+ await collect();
227
+ return files;
228
+ }
229
+ /** The caller's view is stale (e.g. drift captured as its own entry); history stays intact. */
230
+ function checkExpected(expected) {
231
+ if (sameRevision(expected, revisionToken({ current: selected() })))
232
+ return;
233
+ fail('CONFLICT', 'expected revision is stale; capture/refresh and retry');
234
+ }
235
+ async function applyTurn(input, options = {}) {
236
+ return serial(async () => {
237
+ const files = await locked(async () => {
238
+ const base = await captureLocked();
239
+ checkExpected(input.expectedRevision);
240
+ return changeFiles({ files: base, changes: input.changes, paths, limits });
241
+ });
242
+ const diagnostics = await validateCandidate({ validator, candidate: candidateView({ files }), signal: options.signal, timeoutMs: limits.validationTimeoutMs });
243
+ return locked(async () => {
244
+ // Validation can run arbitrary host code; do not silently erase edits made while it ran.
245
+ await captureLocked();
246
+ checkExpected(input.expectedRevision);
247
+ if (options.signal?.aborted)
248
+ fail('CANCELLED', 'turn cancelled');
249
+ if (diagnostics.length)
250
+ return { status: 'rejected', revision: revisionToken({ current: selected() }), diagnostics };
251
+ return commitFiles({ files, label: options.label });
252
+ });
253
+ });
254
+ }
255
+ async function commitFiles({ files, label }) {
256
+ const revisionId = await store.prepare({ files });
257
+ if (revisionId === selected().revision)
258
+ return { status: 'unchanged', revision: revisionToken({ current: selected() }), diagnostics: [] };
259
+ await record({ revisionId, ...(label === undefined ? {} : { label }) });
260
+ await collect();
261
+ return { status: 'committed', revision: revisionToken({ current: selected() }), diagnostics: [] };
262
+ }
263
+ async function replay(side) {
264
+ return serial(() => locked(async () => {
265
+ await captureLocked();
266
+ const entry = await rawHistory[side]();
267
+ await collect();
268
+ return entry;
269
+ }));
270
+ }
271
+ async function restoreRevision({ revisionId, label }) {
272
+ return serial(() => locked(async () => {
273
+ await captureLocked();
274
+ await retainedManifest({ store, revisionId });
275
+ if (selected().revision !== revisionId)
276
+ await record({ revisionId, ...(label === undefined ? {} : { label }) });
277
+ await collect();
278
+ return revisionToken({ current: selected() });
279
+ }));
280
+ }
281
+ async function textSnapshot() {
282
+ return serial(() => locked(async () => {
283
+ const files = await captureLocked();
284
+ const parts = Object.create(null);
285
+ for (const [path, bytes] of files)
286
+ parts[path] = decodeText({ bytes });
287
+ return { id: selected().revision, parts };
288
+ }));
289
+ }
290
+ async function restoreText({ snapshot }) {
291
+ // Snapshot IDs name retained manifests. Replaying them preserves binary files and deletions.
292
+ const before = rawHistory.entries().at(-1);
293
+ await restoreRevision({ revisionId: snapshot.id, label: `restore ${snapshot.id}` });
294
+ const after = rawHistory.entries().at(-1);
295
+ return before === after ? null : after ?? null;
296
+ }
297
+ const history = {
298
+ transaction: async () => fail('INVALID_CHANGE', 'use project.applyTurn for atomic transactions'),
299
+ canUndo: rawHistory.canUndo, canRedo: rawHistory.canRedo,
300
+ undo: () => replay('undo'), redo: () => replay('redo'), restore: restoreText,
301
+ entries: rawHistory.entries, retainedEntries: rawHistory.retainedEntries, clear: rawHistory.clear,
302
+ };
303
+ const target = {
304
+ listParts: () => serial(() => locked(async () => [...(await captureLocked()).keys()].sort().map(id => ({ id })))),
305
+ readPart: ({ id }) => serial(() => locked(async () => {
306
+ paths.check({ relativePath: id });
307
+ const manifest = await store.manifest({ revisionId: selected().revision });
308
+ if (!manifest.files.some(file => file.path === id))
309
+ fail('INVALID_CHANGE', `file not listed: ${id}`);
310
+ return decodeText({ bytes: await store.readWorkingFile({ relativePath: id }) });
311
+ })),
312
+ replacePart: async (edit) => {
313
+ const outcomes = await applyEdits({ edits: [edit] });
314
+ const outcome = outcomes[0];
315
+ if (outcome.status === 'failed')
316
+ throw outcome.error;
317
+ if (outcome.status === 'rejected')
318
+ fail('VALIDATION', outcome.reason);
319
+ },
320
+ validate: async () => ({ ok: true }), // replacePart owns whole-candidate validation, including direct calls.
321
+ snapshot: textSnapshot, restore: async (input) => { await restoreText(input); },
322
+ };
323
+ async function applyEdits({ edits }, options = {}) {
324
+ const expectedRevision = await project.capture();
325
+ const changes = edits.map(edit => ({ op: 'replace', ...edit }));
326
+ try {
327
+ const result = await applyTurn({ expectedRevision, changes }, options);
328
+ if (result.status !== 'rejected')
329
+ return edits.map(({ id }) => ({ status: 'applied', id }));
330
+ const reason = result.diagnostics.map(diagnostic => diagnostic.message).join('\n');
331
+ return edits.map(({ id }) => ({ status: 'rejected', id, reason }));
332
+ }
333
+ catch (error) {
334
+ const failure = error instanceof Error ? error : new Error(String(error));
335
+ return edits.map(({ id }) => ({ status: 'failed', id, error: failure }));
336
+ }
337
+ }
338
+ const project = {
339
+ target, history, applyTurn, applyEdits,
340
+ getRevision: () => serial(() => locked(async () => revisionToken({ current: selected() }))),
341
+ capture: () => serial(() => locked(async () => { await captureLocked(); await collect(); return revisionToken({ current: selected() }); })),
342
+ snapshot: () => serial(() => locked(async () => { await captureLocked(); return { revisionId: selected().revision }; })),
343
+ restore: ({ snapshot }, options = {}) => restoreRevision({ revisionId: snapshot.revisionId, ...options }),
344
+ close: () => serial(() => locked(async () => { rawHistory.clear(); await collect(); closed = true; })),
345
+ };
346
+ return project;
347
+ }
348
+ /** Keep a leading BOM in the text so a read-modify-replace round trip preserves the file's bytes. */
349
+ function decodeText({ bytes }) {
350
+ try {
351
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
352
+ }
353
+ catch (cause) {
354
+ throw new ProjectError({ code: 'INVALID_CHANGE', message: 'binary asset cannot be read as UTF-8 text; use a candidate byte read', cause });
355
+ }
356
+ }
357
+ async function retainedManifest({ store, revisionId }) {
358
+ try {
359
+ await store.manifest({ revisionId });
360
+ }
361
+ catch (cause) {
362
+ if (cause.code === 'ENOENT')
363
+ fail('SNAPSHOT_EXPIRED', 'snapshot revision is no longer retained');
364
+ throw cause;
365
+ }
366
+ }
367
+ //# sourceMappingURL=project.js.map
@@ -0,0 +1,102 @@
1
+ import { type ProjectLimits, type RevisionToken } from '../core/project.js';
2
+ import { type ProjectPaths } from './paths.js';
3
+ export interface ManifestFile {
4
+ readonly path: string;
5
+ readonly hash: string;
6
+ readonly size: number;
7
+ }
8
+ export interface Manifest {
9
+ readonly version: 1;
10
+ readonly files: readonly ManifestFile[];
11
+ }
12
+ interface Fingerprint {
13
+ readonly size: number;
14
+ readonly mtimeMs: number;
15
+ readonly ctimeMs: number;
16
+ }
17
+ export interface Current {
18
+ readonly version: 1;
19
+ readonly root: string;
20
+ readonly revision: string;
21
+ readonly generation: number;
22
+ readonly pending: {
23
+ readonly from: string | null;
24
+ } | null;
25
+ readonly observed: Readonly<Record<string, Fingerprint>>;
26
+ }
27
+ export type StoreCheckpoint = 'objects' | 'manifest' | 'journal' | 'delete' | 'write' | 'prune' | 'clear';
28
+ export interface StoreInput {
29
+ root: string;
30
+ stateDir: string;
31
+ paths: ProjectPaths;
32
+ limits: ProjectLimits;
33
+ }
34
+ export interface StoreOptions {
35
+ /** Internal deterministic process-crash seam; never needed by the public factory. */
36
+ checkpoint?: (input: {
37
+ step: StoreCheckpoint;
38
+ }) => void | Promise<void>;
39
+ }
40
+ /** Immutable object/revision IDs are SHA-256 of their exact bytes. */
41
+ export declare function digest({ bytes }: {
42
+ bytes: Uint8Array | string;
43
+ }, _options?: {}): string;
44
+ /** Host state is physically separate from the watched/served tree, in both directions.
45
+ * @throws ProjectError INVALID_PATH when either canonical directory contains the other.
46
+ * @complexity O(path length) plus constant directory I/O.
47
+ */
48
+ export declare function prepareDirectories({ root, stateDir }: {
49
+ root: string;
50
+ stateDir: string;
51
+ }, _options?: {}): Promise<{
52
+ root: string;
53
+ stateDir: string;
54
+ }>;
55
+ /** Content-addressed persistence and idempotent roll-forward. Call mutations inside locked().
56
+ * @complexity O(bytes + files log files + files × depth²) for capture; memory bounded by maxTreeBytes.
57
+ */
58
+ export declare function createProjectStore({ root, stateDir, paths, limits }: StoreInput, options?: StoreOptions): {
59
+ readCurrent: () => Promise<Current | null>;
60
+ manifest: ({ revisionId }: {
61
+ revisionId: string;
62
+ }) => Promise<Manifest>;
63
+ object: ({ file }: {
64
+ file: ManifestFile;
65
+ }) => Promise<Uint8Array>;
66
+ prepare: ({ files }: {
67
+ files: ReadonlyMap<string, Uint8Array>;
68
+ }) => Promise<string>;
69
+ scan: ({ current }: {
70
+ current: Current | null;
71
+ }) => Promise<{
72
+ files: Map<string, Uint8Array>;
73
+ observed: Record<string, Fingerprint>;
74
+ }>;
75
+ select: ({ current, revisionId, captured }: {
76
+ current: Current | null;
77
+ revisionId: string;
78
+ captured?: Readonly<Record<string, Fingerprint>> | undefined;
79
+ }) => Promise<Current>;
80
+ collect: ({ revisions }: {
81
+ revisions: ReadonlySet<string>;
82
+ }) => Promise<void>;
83
+ preflight: ({ current, revisionId }: {
84
+ current: Current | null;
85
+ revisionId: string;
86
+ }) => Promise<void>;
87
+ observe: ({ current, observed }: {
88
+ current: Current;
89
+ observed: Current["observed"];
90
+ }) => Promise<Current>;
91
+ readWorkingFile: ({ relativePath }: {
92
+ relativePath: string;
93
+ }) => Promise<Uint8Array>;
94
+ locked: <T>(run: () => Promise<T>) => Promise<T>;
95
+ recover: () => Promise<Current | null>;
96
+ };
97
+ export declare function revisionToken({ current }: {
98
+ current: Current;
99
+ }, _options?: {}): RevisionToken;
100
+ export type ProjectStore = ReturnType<typeof createProjectStore>;
101
+ export {};
102
+ //# sourceMappingURL=store.d.ts.map