@hyperframes/studio-server 0.8.150 → 0.8.152

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.
@@ -1,25 +1,26 @@
1
1
  import {
2
2
  isSafePath,
3
3
  mkdirWithinProject,
4
+ pinProjectRecord,
4
5
  realFilePath
5
- } from "./chunk-M62HDRLX.js";
6
+ } from "./chunk-K56J3CL7.js";
6
7
 
7
8
  // src/history/projectHistory.ts
8
- import { randomUUID as randomUUID6 } from "crypto";
9
+ import { randomUUID as randomUUID7 } from "crypto";
9
10
  import { mkdir as mkdir2, readdir as readdir3, rm as rm3, rmdir, writeFile } from "fs/promises";
10
11
  import {
11
- existsSync as existsSync2,
12
- lstatSync as lstatSync2,
13
- statSync as statSync3,
12
+ existsSync as existsSync3,
13
+ lstatSync as lstatSync3,
14
+ statSync as statSync4,
14
15
  mkdirSync as mkdirSync4,
15
16
  readdirSync as readdirSync3,
16
- readFileSync as readFileSync6,
17
+ readFileSync as readFileSync9,
17
18
  realpathSync,
18
19
  rmSync as rmSync2
19
20
  } from "fs";
20
21
  import { homedir } from "os";
21
- import { dirname as dirname4, isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
22
- import { replaceFileAtomically as replaceFileAtomically3 } from "@hyperframes/core/atomic-file";
22
+ import { dirname as dirname4, isAbsolute as isAbsolute5, join as join7, relative as relative4, resolve as resolve6, sep as sep4 } from "path";
23
+ import { replaceFileAtomically as replaceFileAtomically5 } from "@hyperframes/core/atomic-file";
23
24
 
24
25
  // src/helpers/fileVersion.ts
25
26
  import { createHash, randomUUID } from "crypto";
@@ -99,10 +100,143 @@ function newestReceipt(absPath, expectedVersion) {
99
100
  }
100
101
  var consumeFileWriteReceipt = identifyFileWrite;
101
102
 
103
+ // src/helpers/renderOutputs.ts
104
+ import { readFileSync as readFileSync2 } from "fs";
105
+ import { isAbsolute, relative, resolve, sep } from "path";
106
+ import { replaceFileAtomically } from "@hyperframes/core/atomic-file";
107
+
108
+ // src/history/ownerLock.ts
109
+ import { randomUUID as randomUUID2 } from "crypto";
110
+ import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
111
+ import { join } from "path";
112
+ var HistoryBusyError = class extends Error {
113
+ constructor(pid) {
114
+ super(`This project's history is open in another process (pid ${pid}).`);
115
+ this.pid = pid;
116
+ this.name = "HistoryBusyError";
117
+ }
118
+ pid;
119
+ };
120
+ function alive(pid) {
121
+ if (!Number.isInteger(pid) || pid <= 0) return false;
122
+ try {
123
+ process.kill(pid, 0);
124
+ return true;
125
+ } catch (error) {
126
+ return error.code === "EPERM";
127
+ }
128
+ }
129
+ function ownerOf(file) {
130
+ try {
131
+ const text = readFileSync(file, "utf-8");
132
+ return /^\d+$/.test(text) ? Number(text) : Number.NaN;
133
+ } catch (error) {
134
+ if (error.code === "ENOENT") return null;
135
+ throw error;
136
+ }
137
+ }
138
+ function claim(file) {
139
+ const draft = `${file}-${process.pid}-${randomUUID2()}.tmp`;
140
+ writeFileSync(draft, String(process.pid));
141
+ try {
142
+ linkSync(draft, file);
143
+ return true;
144
+ } catch (error) {
145
+ if (error.code !== "EEXIST") throw error;
146
+ return false;
147
+ } finally {
148
+ rmSync(draft, { force: true });
149
+ }
150
+ }
151
+ function releaseOwn(file) {
152
+ if (ownerOf(file) === process.pid) rmSync(file, { force: true });
153
+ }
154
+ function evictDeadOwner(file) {
155
+ const evictor = `${file}.evict`;
156
+ if (!claim(evictor)) {
157
+ const holder = ownerOf(evictor);
158
+ if (holder !== null && !alive(holder)) rmSync(evictor, { force: true });
159
+ return false;
160
+ }
161
+ try {
162
+ const pid = ownerOf(file);
163
+ if (pid !== null && !alive(pid)) rmSync(file, { force: true });
164
+ return true;
165
+ } finally {
166
+ releaseOwn(evictor);
167
+ }
168
+ }
169
+ async function takeHistoryOwnership(home, waitMs) {
170
+ const file = join(home, "owner.pid");
171
+ const deadline = Date.now() + waitMs;
172
+ mkdirSync(home, { recursive: true });
173
+ for (; ; ) {
174
+ if (claim(file)) {
175
+ let held = true;
176
+ return () => {
177
+ if (held) releaseOwn(file);
178
+ held = false;
179
+ };
180
+ }
181
+ const pid = ownerOf(file);
182
+ if (pid === null) continue;
183
+ if (!alive(pid) && evictDeadOwner(file)) continue;
184
+ if (Date.now() >= deadline) throw new HistoryBusyError(pid);
185
+ await new Promise((settle) => setTimeout(settle, 50));
186
+ }
187
+ }
188
+
189
+ // src/helpers/renderOutputs.ts
190
+ var manifest = ".hyperframes/render-outputs.json";
191
+ function readRenderOutputs(projectDir) {
192
+ const path = pinProjectRecord(projectDir, manifest);
193
+ if (!path) return [];
194
+ let text;
195
+ try {
196
+ text = readFileSync2(path, "utf8");
197
+ } catch (error) {
198
+ const code = error.code;
199
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
200
+ throw error;
201
+ }
202
+ const value = JSON.parse(text);
203
+ if (value.version !== 1 || !Array.isArray(value.paths) || !value.paths.every(
204
+ (p) => typeof p === "string" && p !== "" && !isAbsolute(p) && !p.split(/[\\/]/).some((part) => part === ".." || part === ".")
205
+ ))
206
+ throw new Error("Invalid render output registry.");
207
+ return value.paths;
208
+ }
209
+ function readRenderOutputsOrNone(projectDir) {
210
+ try {
211
+ return readRenderOutputs(projectDir);
212
+ } catch {
213
+ return [];
214
+ }
215
+ }
216
+ function isRenderOutput(projectDir, path, outputs) {
217
+ const candidate = relative(resolve(projectDir), resolve(path)).split(sep).join("/");
218
+ return outputs.some((output) => candidate === output || candidate.startsWith(`${output}/`));
219
+ }
220
+ async function registerRenderOutput(projectDir, output) {
221
+ const path = relative(resolve(projectDir), resolve(output)).split(sep).join("/");
222
+ if (path === "") throw new Error("Render output cannot replace the project directory.");
223
+ if (isAbsolute(path) || path === ".." || path.startsWith("../")) return;
224
+ const registry = pinProjectRecord(projectDir, manifest);
225
+ const lock = pinProjectRecord(projectDir, ".hyperframes/render-outputs-lock");
226
+ if (!registry || !lock) throw new Error("Render output registry must stay inside the project.");
227
+ const release = await takeHistoryOwnership(lock, 5e3);
228
+ try {
229
+ const paths = [.../* @__PURE__ */ new Set([...readRenderOutputs(projectDir), path])].sort();
230
+ replaceFileAtomically(registry, JSON.stringify({ version: 1, paths }) + "\n", 420);
231
+ } finally {
232
+ release();
233
+ }
234
+ }
235
+
102
236
  // src/helpers/projectSignature.ts
103
237
  import { createHash as createHash2 } from "crypto";
104
- import { lstatSync, readFileSync, readdirSync } from "fs";
105
- import { extname, isAbsolute, relative, resolve, sep } from "path";
238
+ import { lstatSync, readFileSync as readFileSync3, readdirSync } from "fs";
239
+ import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
106
240
  import { isAtomicTempPath } from "@hyperframes/core/atomic-file";
107
241
  var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
108
242
  ".cjs",
@@ -138,25 +272,36 @@ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
138
272
  ".hyperframes/studio-motion.json"
139
273
  ];
140
274
  function affectsProjectSignature(projectDir, changedPath) {
141
- const relativePath = relative(resolve(projectDir), resolve(changedPath));
142
- if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath) || isAtomicTempPath(relativePath)) {
275
+ const relativePath = relative2(resolve2(projectDir), resolve2(changedPath));
276
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath) || isAtomicTempPath(relativePath)) {
143
277
  return false;
144
278
  }
145
- const segments = relativePath.split(sep);
279
+ if (isRenderOutput(projectDir, changedPath, readRenderOutputsOrNone(projectDir))) return false;
280
+ const segments = relativePath.split(sep2);
146
281
  if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
147
282
  return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
148
283
  }
149
284
  var projectSignatureCache = /* @__PURE__ */ new Map();
150
285
  var COARSEST_FILE_TIME_TICK_MS = 2e3;
151
286
  function isPathWithin(parentDir, childPath) {
152
- const childRelativePath = relative(parentDir, childPath);
153
- return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
287
+ const childRelativePath = relative2(parentDir, childPath);
288
+ return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute2(childRelativePath);
154
289
  }
155
290
  function isTextContentEligible(file, size) {
156
291
  return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
157
292
  }
158
293
  var isSkippedEntry = (entry) => SIGNATURE_EXCLUDED_DIRS.has(entry) || isAtomicTempPath(entry);
159
- function collectProjectSignatureFiles(projectDir, dir, files) {
294
+ function signatureEntryStat(projectDir, entry, file, outputs) {
295
+ if (isSkippedEntry(entry) || !isPathWithin(projectDir, file)) return null;
296
+ if (isRenderOutput(projectDir, file, outputs)) return null;
297
+ try {
298
+ const stat2 = lstatSync(file);
299
+ return stat2.isSymbolicLink() ? null : stat2;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+ function collectProjectSignatureFiles(projectDir, dir, files, outputs = readRenderOutputsOrNone(projectDir)) {
160
305
  let entries;
161
306
  try {
162
307
  entries = readdirSync(dir).sort();
@@ -164,18 +309,11 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
164
309
  return;
165
310
  }
166
311
  for (const entry of entries) {
167
- if (isSkippedEntry(entry)) continue;
168
- const file = resolve(dir, entry);
169
- if (!isPathWithin(projectDir, file)) continue;
170
- let stat2;
171
- try {
172
- stat2 = lstatSync(file);
173
- } catch {
174
- continue;
175
- }
176
- if (stat2.isSymbolicLink()) continue;
312
+ const file = resolve2(dir, entry);
313
+ const stat2 = signatureEntryStat(projectDir, entry, file, outputs);
314
+ if (!stat2) continue;
177
315
  if (stat2.isDirectory()) {
178
- collectProjectSignatureFiles(projectDir, file, files);
316
+ collectProjectSignatureFiles(projectDir, file, files, outputs);
179
317
  } else if (stat2.isFile()) {
180
318
  files.push({
181
319
  file,
@@ -190,7 +328,7 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
190
328
  function collectProjectSignatureManifestFiles(projectDir, files) {
191
329
  const seen = new Set(files.map((entry) => entry.file));
192
330
  for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
193
- const file = resolve(projectDir, manifestPath);
331
+ const file = resolve2(projectDir, manifestPath);
194
332
  if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
195
333
  let stat2;
196
334
  try {
@@ -212,7 +350,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
212
350
  function createProjectFingerprint(projectDir, files) {
213
351
  const hash = createHash2("sha256");
214
352
  for (const entry of files) {
215
- hash.update(relative(projectDir, entry.file));
353
+ hash.update(relative2(projectDir, entry.file));
216
354
  hash.update("\0");
217
355
  hash.update(String(entry.size));
218
356
  hash.update("\0");
@@ -232,9 +370,9 @@ function collectProjectFiles(normalizedProjectDir) {
232
370
  return collected;
233
371
  }
234
372
  function listProjectFiles(projectDir) {
235
- const normalizedProjectDir = resolve(projectDir);
373
+ const normalizedProjectDir = resolve2(projectDir);
236
374
  return collectProjectFiles(normalizedProjectDir).map((entry) => ({
237
- path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
375
+ path: relative2(normalizedProjectDir, entry.file).split(sep2).join("/"),
238
376
  size: entry.size,
239
377
  mtimeMs: entry.mtimeMs,
240
378
  ctimeMs: entry.ctimeMs
@@ -249,11 +387,11 @@ async function resolveProjectAndSignature(adapter, projectId) {
249
387
  return { project, signature: resolveProjectSignature(adapter, project.dir) };
250
388
  }
251
389
  function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set()) {
252
- const normalizedProjectDir = resolve(projectDir);
390
+ const normalizedProjectDir = resolve2(projectDir);
253
391
  const signedAt = Date.now();
254
392
  const collected = collectProjectFiles(normalizedProjectDir);
255
393
  const files = collected.filter(
256
- (entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
394
+ (entry) => !excluding.has(relative2(normalizedProjectDir, entry.file).split(sep2).join("/"))
257
395
  );
258
396
  files.sort((a, b) => a.file.localeCompare(b.file));
259
397
  const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
@@ -262,14 +400,14 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
262
400
  if (cached?.fingerprint === fingerprint) return cached.signature;
263
401
  const hash = createHash2("sha256");
264
402
  for (const entry of files) {
265
- const relativePath = relative(normalizedProjectDir, entry.file);
403
+ const relativePath = relative2(normalizedProjectDir, entry.file);
266
404
  hash.update(relativePath);
267
405
  hash.update("\0");
268
406
  hash.update(String(entry.size));
269
407
  hash.update("\0");
270
408
  if (entry.textContentEligible) {
271
409
  try {
272
- hash.update(readFileSync(entry.file));
410
+ hash.update(readFileSync3(entry.file));
273
411
  } catch {
274
412
  hash.update(String(entry.mtimeMs));
275
413
  }
@@ -287,15 +425,15 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
287
425
  }
288
426
 
289
427
  // src/history/blobStore.ts
290
- import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
428
+ import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
291
429
  import { constants, createReadStream, renameSync } from "fs";
292
430
  import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "fs/promises";
293
- import { dirname, join } from "path";
431
+ import { dirname, join as join2 } from "path";
294
432
  import { atomicTempPath } from "@hyperframes/core/atomic-file";
295
433
  var BLOB_HASH = /^[0-9a-f]{64}$/;
296
434
  function blobPath(dir, hash) {
297
435
  if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
298
- return join(dir, hash.slice(0, 2), hash);
436
+ return join2(dir, hash.slice(0, 2), hash);
299
437
  }
300
438
  var readBlob = (dir, hash) => readFile(blobPath(dir, hash));
301
439
  async function hashFile(path) {
@@ -319,13 +457,13 @@ async function openBlobStore(dir) {
319
457
  const sizes = /* @__PURE__ */ new Map();
320
458
  await mkdir(dir, { recursive: true });
321
459
  for (const shard of await readdir(dir))
322
- for (const hash of await readdir(join(dir, shard)).catch(() => []))
323
- sizes.set(hash, (await stat(join(dir, shard, hash))).size);
460
+ for (const hash of await readdir(join2(dir, shard)).catch(() => []))
461
+ sizes.set(hash, (await stat(join2(dir, shard, hash))).size);
324
462
  const pathOf = (hash) => blobPath(dir, hash);
325
463
  let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
326
464
  return {
327
465
  async put(absPath) {
328
- const temp = join(dir, `incoming-${randomUUID2()}`);
466
+ const temp = join2(dir, `incoming-${randomUUID3()}`);
329
467
  await mkdir(dir, { recursive: true });
330
468
  await copyFile(absPath, temp, constants.COPYFILE_FICLONE);
331
469
  const hash = await hashFile(temp);
@@ -358,22 +496,22 @@ async function openBlobStore(dir) {
358
496
 
359
497
  // src/history/pruneHistories.ts
360
498
  import { randomUUID as randomUUID5 } from "crypto";
361
- import { readdirSync as readdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
499
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
362
500
  import { lstat, readdir as readdir2, rm as rm2 } from "fs/promises";
363
501
  import { tmpdir } from "os";
364
502
  import { dirname as dirname2, join as join4 } from "path";
365
503
 
366
504
  // src/history/historyId.ts
367
- import { randomUUID as randomUUID3 } from "crypto";
368
- import { existsSync, mkdirSync, readFileSync as readFileSync2, statSync, writeFileSync } from "fs";
369
- import { basename, join as join2, resolve as resolve2 } from "path";
370
- import { replaceFileAtomically } from "@hyperframes/core/atomic-file";
371
- var ID_PATH = join2(".hyperframes", "history-id");
505
+ import { randomUUID as randomUUID4 } from "crypto";
506
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync2 } from "fs";
507
+ import { basename, join as join3, resolve as resolve3 } from "path";
508
+ import { replaceFileAtomically as replaceFileAtomically2 } from "@hyperframes/core/atomic-file";
509
+ var ID_PATH = join3(".hyperframes", "history-id");
372
510
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
373
511
  var isHistoryId = (name) => ID_SHAPE.test(name);
374
512
  function readId(projectDir) {
375
513
  try {
376
- const id = readFileSync2(join2(projectDir, ID_PATH), "utf-8").trim();
514
+ const id = readFileSync4(join3(projectDir, ID_PATH), "utf-8").trim();
377
515
  return ID_SHAPE.test(id) ? id : null;
378
516
  } catch {
379
517
  return null;
@@ -390,7 +528,7 @@ function isRecordedFolder(historyDir, folder) {
390
528
  }
391
529
  function readRecord(historyDir) {
392
530
  try {
393
- return JSON.parse(readFileSync2(join2(historyDir, "project.json"), "utf-8"));
531
+ return JSON.parse(readFileSync4(join3(historyDir, "project.json"), "utf-8"));
394
532
  } catch {
395
533
  return null;
396
534
  }
@@ -405,20 +543,20 @@ function isCopyOf0878Folder(recordedDir, id, folder) {
405
543
  return !!there && !sameFolder(there, folder) && readId(recordedDir) === id;
406
544
  }
407
545
  function projectHistoryId(projectDir, historyRoot) {
408
- const dir = resolve2(projectDir);
546
+ const dir = resolve3(projectDir);
409
547
  const folder = statSync(dir);
410
548
  let id = readId(dir);
411
- if (!id && existsSync(join2(dir, ID_PATH)))
549
+ if (!id && existsSync(join3(dir, ID_PATH)))
412
550
  throw new HistoryIdError(
413
- `${join2(dir, ID_PATH)} holds no history id this version can read; move it aside to start anew.`
551
+ `${join3(dir, ID_PATH)} holds no history id this version can read; move it aside to start anew.`
414
552
  );
415
- if (!id || existsSync(join2(historyRoot, id)) && !isRecordedFolder(join2(historyRoot, id), folder)) {
416
- id = randomUUID3();
417
- mkdirWithinProject(dir, join2(dir, ".hyperframes"));
418
- writeFileSync(join2(dir, ID_PATH), `${id}
553
+ if (!id || existsSync(join3(historyRoot, id)) && !isRecordedFolder(join3(historyRoot, id), folder)) {
554
+ id = randomUUID4();
555
+ mkdirWithinProject(dir, join3(dir, ".hyperframes"));
556
+ writeFileSync2(join3(dir, ID_PATH), `${id}
419
557
  `);
420
558
  }
421
- recordProject(join2(historyRoot, id), dir, folder);
559
+ recordProject(join3(historyRoot, id), dir, folder);
422
560
  return id;
423
561
  }
424
562
  var HistoryIdError = class extends Error {
@@ -429,89 +567,8 @@ var HistoryIdError = class extends Error {
429
567
  };
430
568
  function recordProject(historyDir, dir, folder) {
431
569
  const record = { dir, ino: folder.ino, born: folder.birthtimeMs, dev: folder.dev };
432
- mkdirSync(historyDir, { recursive: true });
433
- replaceFileAtomically(join2(historyDir, "project.json"), JSON.stringify(record), 420);
434
- }
435
-
436
- // src/history/ownerLock.ts
437
- import { randomUUID as randomUUID4 } from "crypto";
438
- import { linkSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
439
- import { join as join3 } from "path";
440
- var HistoryBusyError = class extends Error {
441
- constructor(pid) {
442
- super(`This project's history is open in another process (pid ${pid}).`);
443
- this.pid = pid;
444
- this.name = "HistoryBusyError";
445
- }
446
- pid;
447
- };
448
- function alive(pid) {
449
- if (!Number.isInteger(pid) || pid <= 0) return false;
450
- try {
451
- process.kill(pid, 0);
452
- return true;
453
- } catch (error) {
454
- return error.code === "EPERM";
455
- }
456
- }
457
- function ownerOf(file) {
458
- try {
459
- const text = readFileSync3(file, "utf-8");
460
- return /^\d+$/.test(text) ? Number(text) : Number.NaN;
461
- } catch (error) {
462
- if (error.code === "ENOENT") return null;
463
- throw error;
464
- }
465
- }
466
- function claim(file) {
467
- const draft = `${file}-${randomUUID4()}.tmp`;
468
- writeFileSync2(draft, String(process.pid));
469
- try {
470
- linkSync(draft, file);
471
- return true;
472
- } catch (error) {
473
- if (error.code !== "EEXIST") throw error;
474
- return false;
475
- } finally {
476
- rmSync(draft, { force: true });
477
- }
478
- }
479
- function releaseOwn(file) {
480
- if (ownerOf(file) === process.pid) rmSync(file, { force: true });
481
- }
482
- function evictDeadOwner(file) {
483
- const evictor = `${file}.evict`;
484
- if (!claim(evictor)) {
485
- const holder = ownerOf(evictor);
486
- if (holder !== null && !alive(holder)) rmSync(evictor, { force: true });
487
- return false;
488
- }
489
- try {
490
- const pid = ownerOf(file);
491
- if (pid !== null && !alive(pid)) rmSync(file, { force: true });
492
- return true;
493
- } finally {
494
- releaseOwn(evictor);
495
- }
496
- }
497
- async function takeHistoryOwnership(home, waitMs) {
498
- const file = join3(home, "owner.pid");
499
- const deadline = Date.now() + waitMs;
500
- mkdirSync2(home, { recursive: true });
501
- for (; ; ) {
502
- if (claim(file)) {
503
- let held = true;
504
- return () => {
505
- if (held) releaseOwn(file);
506
- held = false;
507
- };
508
- }
509
- const pid = ownerOf(file);
510
- if (pid === null) continue;
511
- if (!alive(pid) && evictDeadOwner(file)) continue;
512
- if (Date.now() >= deadline) throw new HistoryBusyError(pid);
513
- await new Promise((settle) => setTimeout(settle, 50));
514
- }
570
+ mkdirSync2(historyDir, { recursive: true });
571
+ replaceFileAtomically2(join3(historyDir, "project.json"), JSON.stringify(record), 420);
515
572
  }
516
573
 
517
574
  // src/history/pruneHistories.ts
@@ -644,7 +701,7 @@ async function bytesIn(dir) {
644
701
  }
645
702
  function prunedWithinADay(stamp, now) {
646
703
  try {
647
- const last = Number(readFileSync4(stamp, "utf-8"));
704
+ const last = Number(readFileSync5(stamp, "utf-8"));
648
705
  return last <= now && now - last < DAY_MS;
649
706
  } catch {
650
707
  return false;
@@ -660,10 +717,357 @@ function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs }
660
717
  });
661
718
  }
662
719
 
720
+ // src/history/videoState.ts
721
+ import { createHash as createHash5, randomUUID as randomUUID6 } from "crypto";
722
+ import { createReadStream as createReadStream2, existsSync as existsSync2, readFileSync as readFileSync7, renameSync as renameSync3, statSync as statSync3 } from "fs";
723
+ import { isAbsolute as isAbsolute4, join as join6, resolve as resolve5 } from "path";
724
+ import { replaceFileAtomically as replaceFileAtomically3 } from "@hyperframes/core/atomic-file";
725
+
726
+ // src/history/artifactCleanup.ts
727
+ import { createHash as createHash4 } from "crypto";
728
+ import {
729
+ closeSync,
730
+ constants as constants2,
731
+ fstatSync,
732
+ lstatSync as lstatSync2,
733
+ openSync,
734
+ readFileSync as readFileSync6,
735
+ unlinkSync
736
+ } from "fs";
737
+ import { isAbsolute as isAbsolute3, join as join5, relative as relative3, resolve as resolve4, sep as sep3 } from "path";
738
+ var kinds = ["checks", "snapshots"];
739
+ var records = (state, kind) => [
740
+ ...Object.values(state[kind].revisions).flat(),
741
+ ...state[kind].unversioned
742
+ ];
743
+ var protectedRevisions = (state) => /* @__PURE__ */ new Set([state.currentRevision, ...state.renders?.retainedRevisions ?? []]);
744
+ function managedPath(projectDir, path, kind) {
745
+ const canonicalProject = realFilePath(resolve4(projectDir));
746
+ const canonicalPath = realFilePath(path);
747
+ const root = join5(canonicalProject, ".hyperframes", "artifacts", kind);
748
+ const rel = relative3(root, canonicalPath);
749
+ return isAbsolute3(path) && rel !== "" && rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute3(rel) && pinProjectRecord(canonicalProject, relative3(canonicalProject, canonicalPath)) === canonicalPath;
750
+ }
751
+ function updateLatest(state) {
752
+ for (const kind of kinds) {
753
+ const available = records(state, kind).filter((record) => record.status !== "cleaned");
754
+ if (available.some(
755
+ (record) => record.id === state[kind].latest?.id && record.revision === state[kind].latest?.revision
756
+ ))
757
+ continue;
758
+ const latest = available.sort((a, b) => b.createdAt - a.createdAt)[0];
759
+ state[kind].latest = latest ? { revision: latest.revision, id: latest.id } : null;
760
+ }
761
+ }
762
+ function planArtifactCleanup(projectDir, state, render) {
763
+ const protectedIds = protectedRevisions(state);
764
+ const candidates = [];
765
+ const keepPaths = /* @__PURE__ */ new Set();
766
+ const removable = (record, path, kind) => Boolean(record.revision) && !protectedIds.has(record.revision) && record.createdAt <= render.completedAt && managedPath(projectDir, path, kind);
767
+ for (const kind of kinds) {
768
+ for (const record of records(state, kind)) {
769
+ const path = record.image ?? record.report;
770
+ if (!path || record.status === "cleaned") continue;
771
+ if (removable(record, path, kind)) candidates.push({ kind, record, path });
772
+ else keepPaths.add(realFilePath(path));
773
+ }
774
+ }
775
+ for (const { kind, record, path } of candidates) {
776
+ if (!keepPaths.has(realFilePath(path))) markCleaned(state, kind, record, path, render.id);
777
+ }
778
+ updateLatest(state);
779
+ }
780
+ function markCleaned(state, kind, record, path, renderId) {
781
+ (state.pendingArtifactCleanup ??= []).push({
782
+ path,
783
+ hash: record.contentHash,
784
+ revision: record.revision,
785
+ id: record.id,
786
+ kind
787
+ });
788
+ delete record.image;
789
+ delete record.report;
790
+ record.status = "cleaned";
791
+ record.cleanedAfterRender = renderId;
792
+ }
793
+ function restoreCleaned(state, item) {
794
+ const record = state[item.kind].revisions[item.revision]?.find(
795
+ (candidate) => candidate.id === item.id
796
+ );
797
+ if (record?.status !== "cleaned") return;
798
+ if (item.kind === "checks") record.report = item.path;
799
+ else record.image = item.path;
800
+ delete record.status;
801
+ delete record.cleanedAfterRender;
802
+ }
803
+ function stillPendingAfterUnlink(item) {
804
+ let descriptor;
805
+ try {
806
+ descriptor = openSync(item.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
807
+ const stat2 = fstatSync(descriptor);
808
+ if (!stat2.isFile() || stat2.isSymbolicLink()) return false;
809
+ const hash = createHash4("sha256").update(readFileSync6(descriptor)).digest("hex");
810
+ if (hash !== item.hash) return false;
811
+ const current = lstatSync2(item.path);
812
+ if (current.dev !== stat2.dev || current.ino !== stat2.ino) return false;
813
+ unlinkSync(item.path);
814
+ return false;
815
+ } catch (error) {
816
+ return error.code !== "ENOENT";
817
+ } finally {
818
+ if (descriptor !== void 0) closeSync(descriptor);
819
+ }
820
+ }
821
+ function finishArtifactCleanup(projectDir, state) {
822
+ const protectedIds = protectedRevisions(state);
823
+ const referenced = new Set(
824
+ kinds.flatMap(
825
+ (kind) => records(state, kind).flatMap((record) => record.image ?? record.report ?? []).map(realFilePath)
826
+ )
827
+ );
828
+ state.pendingArtifactCleanup = (state.pendingArtifactCleanup ?? []).filter((item) => {
829
+ if (!managedPath(projectDir, item.path, item.kind)) return false;
830
+ const canonicalPath = realFilePath(item.path);
831
+ if (protectedIds.has(item.revision)) {
832
+ restoreCleaned(state, item);
833
+ return false;
834
+ }
835
+ if (state.pendingChanges) return true;
836
+ if (referenced.has(canonicalPath)) return false;
837
+ return stillPendingAfterUnlink({ ...item, path: canonicalPath });
838
+ });
839
+ updateLatest(state);
840
+ }
841
+
842
+ // src/history/videoState.ts
843
+ function statePath(dir) {
844
+ const path = pinProjectRecord(dir, ".hyperframes/video-state.json");
845
+ if (!path) throw new Error("Video state must stay inside the project.");
846
+ return path;
847
+ }
848
+ var collection = () => ({ latest: null, revisions: {}, unversioned: [] });
849
+ var emptyState = () => ({
850
+ version: 1,
851
+ historyId: null,
852
+ currentRevision: null,
853
+ pendingChanges: true,
854
+ files: {},
855
+ revisions: {},
856
+ checks: collection(),
857
+ snapshots: collection()
858
+ });
859
+ function readState(dir) {
860
+ if (!existsSync2(statePath(dir))) return emptyState();
861
+ const state = JSON.parse(readFileSync7(statePath(dir), "utf8"));
862
+ if (state.version !== 1 || !state.checks || !state.snapshots || !state.revisions || !state.files)
863
+ throw new Error("Unsupported video-state schema.");
864
+ return state;
865
+ }
866
+ async function updateState(dir, update) {
867
+ const lock = pinProjectRecord(dir, ".hyperframes/video-state-lock");
868
+ if (!lock) throw new Error("Video state lock must stay inside the project.");
869
+ const release = await takeHistoryOwnership(lock, 5e3);
870
+ try {
871
+ let state;
872
+ try {
873
+ state = readState(dir);
874
+ } catch (error) {
875
+ if (!(error instanceof SyntaxError)) throw error;
876
+ renameSync3(statePath(dir), `${statePath(dir)}.invalid-${randomUUID6()}`);
877
+ state = emptyState();
878
+ }
879
+ update(state);
880
+ replaceFileAtomically3(statePath(dir), JSON.stringify(state, null, 2) + "\n", 420);
881
+ } finally {
882
+ release();
883
+ }
884
+ }
885
+ async function writeHistoryState(projectDir, historyId, historyDirectory, entries, currentFiles, committedFiles) {
886
+ const blob = (hash) => hash === null ? null : resolve5(historyDirectory, "blobs", hash.slice(0, 2), hash);
887
+ await updateState(projectDir, (state) => {
888
+ if (state.historyId && state.historyId !== historyId) {
889
+ state.checks = collection();
890
+ state.snapshots = collection();
891
+ }
892
+ state.historyId = historyId;
893
+ state.currentRevision = entries.at(-1)?.id ?? null;
894
+ state.pendingChanges = currentFiles.size !== committedFiles.size || [...currentFiles].some(([path, hash]) => committedFiles.get(path) !== hash);
895
+ state.files = Object.fromEntries(
896
+ [...currentFiles].map(([path, hash]) => [path, { hash, content: blob(hash) }])
897
+ );
898
+ state.revisions = Object.fromEntries(
899
+ entries.map((entry) => [entry.id, revisionState(entry, blob)])
900
+ );
901
+ dropArtifactsOfGoneRevisions(state);
902
+ });
903
+ }
904
+ function revisionState(entry, blob) {
905
+ const targets = entry.targets?.filter(
906
+ (target) => entry.files.some((file) => file.path === target.file)
907
+ );
908
+ return {
909
+ actor: entry.who,
910
+ ...targets?.length && { targets },
911
+ label: entry.label,
912
+ startedAt: entry.startedAt,
913
+ endedAt: entry.endedAt,
914
+ ...entry.undoes && { undoes: entry.undoes },
915
+ ...entry.restoredTo && { restoredTo: entry.restoredTo },
916
+ files: Object.fromEntries(
917
+ entry.files.map((file) => [
918
+ file.path,
919
+ { before: blob(file.before), after: blob(file.after) }
920
+ ])
921
+ )
922
+ };
923
+ }
924
+ function dropArtifactsOfGoneRevisions(state) {
925
+ const kept = (revision) => Boolean(state.revisions[revision]) || Boolean(state.renders?.retainedRevisions?.includes(revision));
926
+ for (const artifacts of [state.checks, state.snapshots]) {
927
+ for (const revision of Object.keys(artifacts.revisions)) {
928
+ if (!kept(revision)) delete artifacts.revisions[revision];
929
+ }
930
+ if (artifacts.latest?.revision && !state.revisions[artifacts.latest.revision])
931
+ artifacts.latest = null;
932
+ }
933
+ }
934
+ async function fileHashes(dir) {
935
+ const files = {};
936
+ for (const { path } of listProjectFiles(dir).sort((a, b) => a.path.localeCompare(b.path))) {
937
+ const hash = createHash5("sha256");
938
+ for await (const chunk of createReadStream2(join6(dir, path))) hash.update(chunk);
939
+ files[path] = hash.digest("hex");
940
+ }
941
+ return files;
942
+ }
943
+ async function beginArtifactCapture(projectDir) {
944
+ const files = await fileHashes(projectDir);
945
+ const state = readState(projectDir);
946
+ const matches = state.historyId !== null && readId(projectDir) === state.historyId && !state.pendingChanges && Object.entries(state.files).every(([path, file]) => files[path] === file.hash) && Object.keys(files).filter((path) => !path.split("/").some((part) => part.startsWith("."))).every((path) => state.files[path]?.hash === files[path]);
947
+ return {
948
+ revision: matches ? state.currentRevision : null,
949
+ fingerprint: createHash5("sha256").update(JSON.stringify(files)).digest("hex")
950
+ };
951
+ }
952
+ async function recordArtifact(projectDir, capture, artifact) {
953
+ return recordArtifacts(projectDir, capture, [artifact]);
954
+ }
955
+ async function recordArtifacts(projectDir, capture, artifactsToRecord) {
956
+ const records2 = [];
957
+ const hashes = /* @__PURE__ */ new Map();
958
+ for (const artifact of artifactsToRecord) {
959
+ const path = artifact.kind === "check" ? artifact.report : artifact.image;
960
+ if (!path || !isAbsolute4(path)) throw new Error("Artifact needs an absolute output path.");
961
+ hashes.set(artifact.id, createHash5("sha256").update(readFileSync7(path)).digest("hex"));
962
+ }
963
+ const after = await beginArtifactCapture(projectDir);
964
+ const unchanged = after.fingerprint === capture.fingerprint;
965
+ await updateState(projectDir, (state) => {
966
+ const revision = unchanged ? knownRevision(state, capture.revision) : null;
967
+ const attribution = attributionOf(unchanged, revision);
968
+ for (const artifact of artifactsToRecord) {
969
+ const { kind, ...fields } = artifact;
970
+ const record = {
971
+ ...fields,
972
+ createdAt: Date.now(),
973
+ contentHash: hashes.get(artifact.id),
974
+ revision,
975
+ inputFingerprint: capture.fingerprint,
976
+ inputScope: "local-project-files",
977
+ attribution
978
+ };
979
+ const artifacts = kind === "check" ? state.checks : state.snapshots;
980
+ dropSupersededArtifacts(artifacts, artifact);
981
+ const list = revision ? artifacts.revisions[revision] ??= [] : artifacts.unversioned;
982
+ list.push(record);
983
+ records2.push(record);
984
+ artifacts.latest = { revision, id: record.id };
985
+ }
986
+ });
987
+ return records2;
988
+ }
989
+ function knownRevision(state, revision) {
990
+ return revision && state.revisions[revision] ? revision : null;
991
+ }
992
+ function attributionOf(unchanged, revision) {
993
+ if (!unchanged) return "inputs-changed";
994
+ return revision ? "recorded-revision" : "unversioned";
995
+ }
996
+ function supersedes(artifact, previous) {
997
+ if (previous.status !== "cleaned" && !existsSync2(previous.image ?? previous.report ?? ""))
998
+ return true;
999
+ if (previous.id === artifact.id) return true;
1000
+ if (artifact.image && previous.image === artifact.image) return true;
1001
+ return Boolean(artifact.report && previous.report === artifact.report);
1002
+ }
1003
+ function dropSupersededArtifacts(artifacts, artifact) {
1004
+ for (const list of [...Object.values(artifacts.revisions), artifacts.unversioned]) {
1005
+ for (let i = list.length - 1; i >= 0; i--) {
1006
+ if (supersedes(artifact, list[i])) list.splice(i, 1);
1007
+ }
1008
+ }
1009
+ }
1010
+ function listsOf(artifacts) {
1011
+ return [...Object.values(artifacts.revisions), artifacts.unversioned];
1012
+ }
1013
+ async function forgetRecordedArtifacts(projectDir, paths) {
1014
+ const gone = new Set(paths.map((path) => resolve5(path)));
1015
+ if (gone.size === 0) return;
1016
+ await updateState(projectDir, (state) => {
1017
+ for (const artifacts of [state.checks, state.snapshots]) {
1018
+ for (const list of listsOf(artifacts)) {
1019
+ for (let i = list.length - 1; i >= 0; i--) {
1020
+ const record = list[i];
1021
+ const path = record.image ?? record.report;
1022
+ if (path && gone.has(resolve5(path))) list.splice(i, 1);
1023
+ }
1024
+ }
1025
+ if (artifacts.latest && !listsOf(artifacts).some(
1026
+ (list) => list.some((record) => record.id === artifacts.latest?.id)
1027
+ )) {
1028
+ artifacts.latest = null;
1029
+ }
1030
+ }
1031
+ });
1032
+ }
1033
+ function artifactDirectory(projectDir, kind) {
1034
+ return join6(resolve5(projectDir), ".hyperframes", "artifacts", kind, randomUUID6());
1035
+ }
1036
+ function retainRevision(retained, revision) {
1037
+ if (!revision) return [...retained];
1038
+ return [revision, ...retained.filter((id) => id !== revision)].slice(0, 2);
1039
+ }
1040
+ async function recordRenderSuccess(projectDir, output, status = "completed", capture) {
1041
+ const outputFile = statSync3(output);
1042
+ if (!outputFile.isFile() && !outputFile.isDirectory() || outputFile.isFile() && outputFile.size === 0)
1043
+ throw new Error("Render output is missing or empty.");
1044
+ const after = capture ? await beginArtifactCapture(projectDir) : null;
1045
+ const record = {
1046
+ id: randomUUID6(),
1047
+ completedAt: Date.now(),
1048
+ output: resolve5(output),
1049
+ status
1050
+ };
1051
+ const unchanged = Boolean(capture && after?.fingerprint === capture.fingerprint);
1052
+ await updateState(projectDir, (state) => {
1053
+ record.revision = unchanged ? knownRevision(state, capture?.revision) : null;
1054
+ state.renders = {
1055
+ latest: record,
1056
+ retainedRevisions: retainRevision(state.renders?.retainedRevisions ?? [], record.revision)
1057
+ };
1058
+ if (outputFile.isFile() && record.revision && !state.pendingChanges)
1059
+ planArtifactCleanup(projectDir, state, record);
1060
+ });
1061
+ await updateState(projectDir, (state) => {
1062
+ finishArtifactCleanup(projectDir, state);
1063
+ });
1064
+ return record;
1065
+ }
1066
+
663
1067
  // src/history/historyLog.ts
664
- import { closeSync, constants as constants2, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync5, writeSync } from "fs";
1068
+ import { closeSync as closeSync2, constants as constants3, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync8, writeSync } from "fs";
665
1069
  import { dirname as dirname3 } from "path";
666
- import { replaceFileAtomically as replaceFileAtomically2 } from "@hyperframes/core/atomic-file";
1070
+ import { replaceFileAtomically as replaceFileAtomically4 } from "@hyperframes/core/atomic-file";
667
1071
  var START = "start";
668
1072
  function parseRecord(line) {
669
1073
  try {
@@ -675,7 +1079,7 @@ function parseRecord(line) {
675
1079
  function readLog(file, onUnreadable) {
676
1080
  let text;
677
1081
  try {
678
- text = readFileSync5(file, "utf-8");
1082
+ text = readFileSync8(file, "utf-8");
679
1083
  } catch {
680
1084
  return null;
681
1085
  }
@@ -699,7 +1103,7 @@ function applyRecord(log, record) {
699
1103
  function saveRecord(file, log, record) {
700
1104
  let fd;
701
1105
  try {
702
- fd = openSync(file, constants2.O_WRONLY | constants2.O_APPEND);
1106
+ fd = openSync2(file, constants3.O_WRONLY | constants3.O_APPEND);
703
1107
  } catch (error) {
704
1108
  if (error.code !== "ENOENT") throw error;
705
1109
  return writeLog(file, log);
@@ -708,7 +1112,7 @@ function saveRecord(file, log, record) {
708
1112
  writeSync(fd, `${JSON.stringify(record)}
709
1113
  `);
710
1114
  } finally {
711
- closeSync(fd);
1115
+ closeSync2(fd);
712
1116
  }
713
1117
  }
714
1118
  function baselineRecord(log) {
@@ -719,30 +1123,30 @@ function baselineRecord(log) {
719
1123
  };
720
1124
  }
721
1125
  function writeLog(file, log) {
722
- const records = [
1126
+ const records2 = [
723
1127
  baselineRecord(log),
724
1128
  ...log.entries.map((entry) => ({ type: "entry", entry })),
725
1129
  ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
726
1130
  ];
727
1131
  mkdirSync3(dirname3(file), { recursive: true });
728
- replaceFileAtomically2(file, records.map((r) => `${JSON.stringify(r)}
1132
+ replaceFileAtomically4(file, records2.map((r) => `${JSON.stringify(r)}
729
1133
  `).join(""), 420);
730
1134
  }
731
- function applyEntry(manifest, entry, side) {
1135
+ function applyEntry(manifest2, entry, side) {
732
1136
  for (const file of entry.files) {
733
1137
  const hash = file[side];
734
- if (hash === null) manifest.delete(file.path);
735
- else manifest.set(file.path, hash);
1138
+ if (hash === null) manifest2.delete(file.path);
1139
+ else manifest2.set(file.path, hash);
736
1140
  }
737
1141
  }
738
1142
  function manifestAt(log, point) {
739
1143
  return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
740
1144
  }
741
1145
  function manifestAround(log, id, side) {
742
- const manifest = new Map(log.baseline);
1146
+ const manifest2 = new Map(log.baseline);
743
1147
  for (const entry of log.entries) {
744
- applyEntry(manifest, entry, entry.id === id ? side : "after");
745
- if (entry.id === id) return manifest;
1148
+ applyEntry(manifest2, entry, entry.id === id ? side : "after");
1149
+ if (entry.id === id) return manifest2;
746
1150
  }
747
1151
  return null;
748
1152
  }
@@ -797,7 +1201,7 @@ function referencedHashes(log, current) {
797
1201
  }
798
1202
 
799
1203
  // src/history/projectHistory.ts
800
- var DEFAULT_HISTORY_ROOT = join5(homedir(), ".cache", "hyperframes", "history");
1204
+ var DEFAULT_HISTORY_ROOT = join7(homedir(), ".cache", "hyperframes", "history");
801
1205
  var MAX_WINDOW_IDLE_MS = 10 * 6e4;
802
1206
  var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
803
1207
  var OUTSIDE = { kind: "outside", name: "Outside" };
@@ -810,7 +1214,7 @@ function isHistoryPath(path) {
810
1214
  if (KEPT_HIDDEN_PATHS.has(path)) return true;
811
1215
  return !path.split("/").some((segment) => segment.startsWith("."));
812
1216
  }
813
- var logFileIn = (home) => join5(home, "log.jsonl");
1217
+ var logFileIn = (home) => join7(home, "log.jsonl");
814
1218
  function listOf(log) {
815
1219
  const undone = undoneIds(log.entries);
816
1220
  return log.entries.map((entry) => ({
@@ -850,20 +1254,20 @@ async function removeEmptyFolders(dir) {
850
1254
  throw error;
851
1255
  }
852
1256
  for (const entry of entries)
853
- if (entry.isDirectory()) await removeEmptyFolders(join5(dir, entry.name));
1257
+ if (entry.isDirectory()) await removeEmptyFolders(join7(dir, entry.name));
854
1258
  await rmdir(dir);
855
1259
  }
856
1260
  function fileLeftIn(dir, at, deleted) {
857
1261
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
858
1262
  const path = `${at}/${entry.name}`;
859
- const left = entry.isDirectory() ? fileLeftIn(join5(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
1263
+ const left = entry.isDirectory() ? fileLeftIn(join7(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
860
1264
  if (left) return left;
861
1265
  }
862
1266
  return void 0;
863
1267
  }
864
1268
  function ignoresCase(dir) {
865
- const self = statSync3(join5(dir, ID_PATH), { throwIfNoEntry: false });
866
- const other = statSync3(join5(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
1269
+ const self = statSync4(join7(dir, ID_PATH), { throwIfNoEntry: false });
1270
+ const other = statSync4(join7(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
867
1271
  return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
868
1272
  }
869
1273
  var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
@@ -886,15 +1290,24 @@ function addChange(group, path, before, after) {
886
1290
  if (from === after) group.changes.delete(path);
887
1291
  else group.changes.set(path, { path, before: from, after });
888
1292
  }
1293
+ var targetKey = (target) => JSON.stringify([target.file, target.id, target.hfId, target.selector, target.selectorIndex]);
1294
+ function keepTargetsOfItsFiles(entry) {
1295
+ const targets = entry.targets?.filter(
1296
+ (target) => entry.files.some((file) => file.path === target.file)
1297
+ );
1298
+ if (targets?.length) entry.targets = targets;
1299
+ else delete entry.targets;
1300
+ return entry;
1301
+ }
889
1302
  var Engine = class {
890
1303
  constructor(options, projectId, blobs) {
891
1304
  this.options = options;
892
1305
  this.projectId = projectId;
893
1306
  this.blobs = blobs;
894
- this.dir = resolve3(options.projectDir);
895
- this.home = join5(options.historyRoot, projectId);
1307
+ this.dir = resolve6(options.projectDir);
1308
+ this.home = join7(options.historyRoot, projectId);
896
1309
  this.foldsCase = ignoresCase(this.dir);
897
- const folder = statSync3(this.dir, { throwIfNoEntry: false });
1310
+ const folder = statSync4(this.dir, { throwIfNoEntry: false });
898
1311
  if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
899
1312
  throw this.replaced();
900
1313
  this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
@@ -938,13 +1351,28 @@ var Engine = class {
938
1351
  queue(task) {
939
1352
  if (this.closed)
940
1353
  return Promise.reject(new HistoryClosedError("This project's history is closed."));
941
- const run = this.tail.then(task);
1354
+ const run = this.tail.then(async () => {
1355
+ try {
1356
+ return await task();
1357
+ } finally {
1358
+ if (this.whereFolder() === "here") {
1359
+ await writeHistoryState(
1360
+ this.dir,
1361
+ this.projectId,
1362
+ this.home,
1363
+ this.log.entries,
1364
+ this.manifest(),
1365
+ manifestAt(this.log, this.log.entries.at(-1)?.id ?? START) ?? /* @__PURE__ */ new Map()
1366
+ ).catch((error) => this.options.onError?.(error));
1367
+ }
1368
+ }
1369
+ });
942
1370
  this.tail = run.catch(() => void 0);
943
1371
  return run;
944
1372
  }
945
1373
  /** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
946
1374
  whereFolder() {
947
- const now = statSync3(this.dir, { throwIfNoEntry: false });
1375
+ const now = statSync4(this.dir, { throwIfNoEntry: false });
948
1376
  if (!now) return "gone";
949
1377
  const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
950
1378
  return same && readId(this.dir) === this.projectId ? "here" : "replaced";
@@ -957,7 +1385,7 @@ var Engine = class {
957
1385
  persistLog(record) {
958
1386
  if (record) saveRecord(this.logFile, this.log, record);
959
1387
  else writeLog(this.logFile, this.log);
960
- if (!existsSync2(join5(this.home, "project.json")))
1388
+ if (!existsSync3(join7(this.home, "project.json")))
961
1389
  recordProject(this.home, this.dir, this.folder);
962
1390
  }
963
1391
  replaced() {
@@ -996,7 +1424,7 @@ var Engine = class {
996
1424
  if (!after || !affectsProjectSignature(realDir, absPath)) return;
997
1425
  const replaced = hashOfVersion(fileContentVersion(bytes));
998
1426
  if (!replaced || replaced === after) return;
999
- const path = relative2(realDir, absPath).split(sep2).join("/");
1427
+ const path = relative4(realDir, absPath).split(sep4).join("/");
1000
1428
  if (!isHistoryPath(path)) return;
1001
1429
  this.overwritten.set(
1002
1430
  path,
@@ -1008,7 +1436,7 @@ var Engine = class {
1008
1436
  });
1009
1437
  }
1010
1438
  async storeBytes(bytes) {
1011
- const staged = join5(this.home, `overwrote-${randomUUID6()}`);
1439
+ const staged = join7(this.home, `overwrote-${randomUUID7()}`);
1012
1440
  await mkdir2(this.home, { recursive: true });
1013
1441
  await writeFile(staged, bytes);
1014
1442
  try {
@@ -1062,10 +1490,10 @@ var Engine = class {
1062
1490
  this.saveAdopting();
1063
1491
  }
1064
1492
  saveAdopting() {
1065
- const file = join5(this.home, "adopting.json");
1493
+ const file = join7(this.home, "adopting.json");
1066
1494
  if (!this.adopting.size) return rmSync2(file, { force: true });
1067
1495
  mkdirSync4(this.home, { recursive: true });
1068
- replaceFileAtomically3(file, JSON.stringify([...this.adopting.keys()]), 420);
1496
+ replaceFileAtomically5(file, JSON.stringify([...this.adopting.keys()]), 420);
1069
1497
  }
1070
1498
  async adopt(file, sweptAt) {
1071
1499
  try {
@@ -1098,7 +1526,7 @@ var Engine = class {
1098
1526
  }
1099
1527
  readStatCache() {
1100
1528
  try {
1101
- const files = JSON.parse(readFileSync6(join5(this.home, "stat.json"), "utf-8"));
1529
+ const files = JSON.parse(readFileSync9(join7(this.home, "stat.json"), "utf-8"));
1102
1530
  return new Map(Object.entries(files));
1103
1531
  } catch {
1104
1532
  return /* @__PURE__ */ new Map();
@@ -1107,7 +1535,7 @@ var Engine = class {
1107
1535
  readAdopting() {
1108
1536
  try {
1109
1537
  return new Set(
1110
- JSON.parse(readFileSync6(join5(this.home, "adopting.json"), "utf-8"))
1538
+ JSON.parse(readFileSync9(join7(this.home, "adopting.json"), "utf-8"))
1111
1539
  );
1112
1540
  } catch {
1113
1541
  return /* @__PURE__ */ new Set();
@@ -1116,7 +1544,7 @@ var Engine = class {
1116
1544
  saveStatCache() {
1117
1545
  const files = JSON.stringify(Object.fromEntries(this.tracked));
1118
1546
  mkdirSync4(this.home, { recursive: true });
1119
- replaceFileAtomically3(join5(this.home, "stat.json"), files, 420);
1547
+ replaceFileAtomically5(join7(this.home, "stat.json"), files, 420);
1120
1548
  }
1121
1549
  /**
1122
1550
  * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
@@ -1158,7 +1586,7 @@ var Engine = class {
1158
1586
  /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
1159
1587
  async storeIfPresent(path) {
1160
1588
  try {
1161
- return await this.blobs.put(join5(this.dir, path));
1589
+ return await this.blobs.put(join7(this.dir, path));
1162
1590
  } catch (error) {
1163
1591
  if (error.code === "ENOENT") return null;
1164
1592
  throw error;
@@ -1184,7 +1612,7 @@ var Engine = class {
1184
1612
  addChange(window ?? this.outsideGroup(), path, before, after);
1185
1613
  }
1186
1614
  /** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
1187
- async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
1615
+ async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {}, targets = [] }) {
1188
1616
  await this.sweep();
1189
1617
  const taken = await this.takeClaimed(who, paths, overwrote);
1190
1618
  if (!taken.length) return this.claimNothing(coalesceKey);
@@ -1193,10 +1621,19 @@ var Engine = class {
1193
1621
  await this.commitPending();
1194
1622
  const group = held ?? this.newGroup(who, label);
1195
1623
  for (const change of taken) addChange(group, change.path, change.before, change.after);
1624
+ this.addTargets(group, targets);
1196
1625
  if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
1197
1626
  const entry = await this.commit(group);
1198
1627
  return entry && { id: entry.id };
1199
1628
  }
1629
+ addTargets(group, targets) {
1630
+ const unique = new Map((group.targets ?? []).map((target) => [targetKey(target), target]));
1631
+ for (const target of targets) {
1632
+ const normalized = { ...target, file: this.logPath(target.file) };
1633
+ if (group.changes.has(normalized.file)) unique.set(targetKey(normalized), normalized);
1634
+ }
1635
+ group.targets = [...unique.values()];
1636
+ }
1200
1637
  /** A claim that took nothing still ends a held claim under another key. */
1201
1638
  async claimNothing(coalesceKey) {
1202
1639
  if (coalesceKey !== this.claimed?.key) await this.commitClaim();
@@ -1223,7 +1660,7 @@ var Engine = class {
1223
1660
  return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
1224
1661
  }
1225
1662
  logPath(path) {
1226
- return relative2(this.dir, resolve3(this.dir, path)).split(sep2).join("/");
1663
+ return relative4(this.dir, resolve6(this.dir, path)).split(sep4).join("/");
1227
1664
  }
1228
1665
  /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
1229
1666
  async takeClaimed(who, paths, overwrote) {
@@ -1314,7 +1751,7 @@ var Engine = class {
1314
1751
  return this.outside;
1315
1752
  }
1316
1753
  newGroup(who, label) {
1317
- return { id: randomUUID6(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
1754
+ return { id: randomUUID7(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
1318
1755
  }
1319
1756
  async commitOutside() {
1320
1757
  if (this.outside?.changes.size) await this.commitClaim();
@@ -1329,7 +1766,7 @@ var Engine = class {
1329
1766
  if (!group.changes.size && !undoesEmptied) return null;
1330
1767
  const pending = this.pendingEntry(group);
1331
1768
  const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
1332
- const entry = { ...pending, endedAt, ...extra };
1769
+ const entry = keepTargetsOfItsFiles({ ...pending, endedAt, ...extra });
1333
1770
  this.log.entries.push(entry);
1334
1771
  try {
1335
1772
  this.persistLog({ type: "entry", entry });
@@ -1384,9 +1821,9 @@ var Engine = class {
1384
1821
  }
1385
1822
  /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
1386
1823
  noteChange(path) {
1387
- const absolute = resolve3(this.dir, path);
1824
+ const absolute = resolve6(this.dir, path);
1388
1825
  if (this.notedTimer || !affectsProjectSignature(this.dir, absolute)) return;
1389
- if (!isHistoryPath(relative2(this.dir, absolute).split(sep2).join("/"))) return;
1826
+ if (!isHistoryPath(relative4(this.dir, absolute).split(sep4).join("/"))) return;
1390
1827
  this.notedTimer = setTimeout(() => {
1391
1828
  this.notedTimer = null;
1392
1829
  this.background(() => this.sweep());
@@ -1398,9 +1835,19 @@ var Engine = class {
1398
1835
  if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
1399
1836
  });
1400
1837
  }
1401
- beginWindow(who, label, idleMs) {
1838
+ beginWindow(who, label, idleMs, exclusive = false) {
1402
1839
  return this.queue(async () => {
1403
1840
  await this.settle();
1841
+ const at = this.now();
1842
+ if (exclusive) {
1843
+ const blocking = this.windows.find(
1844
+ (open) => !sameWho(open.who, who) && takesWrite(open, at)
1845
+ );
1846
+ if (blocking)
1847
+ throw new Error(`${blocking.who.name}'s turn is open; it has to end before yours begins`);
1848
+ for (const open of [...this.windows])
1849
+ if (!takesWrite(open, at) || sameWho(open.who, who)) await this.endWindow(open);
1850
+ }
1404
1851
  const window = { ...this.newGroup(who, label), idleMs };
1405
1852
  this.windows.push(window);
1406
1853
  this.touch(window, this.now());
@@ -1452,7 +1899,7 @@ var Engine = class {
1452
1899
  const parts = path.split("/");
1453
1900
  for (let depth = 1; depth <= parts.length; depth += 1) {
1454
1901
  const at = parts.slice(0, depth).join("/");
1455
- const stat2 = lstatSync2(join5(this.dir, at), { throwIfNoEntry: false });
1902
+ const stat2 = lstatSync3(join7(this.dir, at), { throwIfNoEntry: false });
1456
1903
  if (!stat2) return void 0;
1457
1904
  if (at === path || !stat2.isDirectory()) return { at, stat: stat2 };
1458
1905
  }
@@ -1464,8 +1911,8 @@ var Engine = class {
1464
1911
  if (!standing) return void 0;
1465
1912
  if (standing.stat.isSymbolicLink()) return standing.at;
1466
1913
  if (standing.at === path && !standing.stat.isDirectory()) return void 0;
1467
- const real = realpathSync.native(join5(this.dir, standing.at));
1468
- const at = relative2(realpathSync.native(this.dir), real).split(sep2).join("/");
1914
+ const real = realpathSync.native(join7(this.dir, standing.at));
1915
+ const at = relative4(realpathSync.native(this.dir), real).split(sep4).join("/");
1469
1916
  if (standing.at !== path) return deleted.has(at) ? void 0 : at;
1470
1917
  return fileLeftIn(real, at, deleted);
1471
1918
  }
@@ -1489,7 +1936,7 @@ var Engine = class {
1489
1936
  /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
1490
1937
  async writeProjectFile(path, hash) {
1491
1938
  this.assertWritable();
1492
- const absPath = join5(this.dir, path);
1939
+ const absPath = join7(this.dir, path);
1493
1940
  const { writeToken } = this;
1494
1941
  if (writeToken) {
1495
1942
  const version = hash === null ? DELETED_VERSION : hashVersion(hash);
@@ -1530,7 +1977,10 @@ var Engine = class {
1530
1977
  );
1531
1978
  return {
1532
1979
  ok: true,
1533
- entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
1980
+ entry: await this.writeAs(who, this.undoLabel(entry), target, {
1981
+ undoes: id,
1982
+ ...entry.targets && { targets: entry.targets }
1983
+ })
1534
1984
  };
1535
1985
  }
1536
1986
  movedOn(entry) {
@@ -1554,7 +2004,15 @@ var Engine = class {
1554
2004
  pendingEntry(group) {
1555
2005
  const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
1556
2006
  const { id, who, label, startedAt, lastWriteAt } = group;
1557
- return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
2007
+ return {
2008
+ id,
2009
+ who,
2010
+ label,
2011
+ startedAt,
2012
+ endedAt: lastWriteAt ?? this.now(),
2013
+ files,
2014
+ ...group.targets?.length && { targets: group.targets }
2015
+ };
1558
2016
  }
1559
2017
  conflict(entry, changed) {
1560
2018
  const paths = new Set(changed.map((file) => file.path));
@@ -1589,7 +2047,12 @@ var Engine = class {
1589
2047
  api() {
1590
2048
  return {
1591
2049
  projectId: this.projectId,
1592
- beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
2050
+ beginWindow: (who, label, options = {}) => this.beginWindow(
2051
+ who,
2052
+ label,
2053
+ options.idleMs ?? this.options.maxGroupMs ?? 3e4,
2054
+ options.exclusive === true
2055
+ ),
1593
2056
  claim: async (who, label, paths, options = {}) => {
1594
2057
  await Promise.all(paths.map((path) => this.adopting.get(this.logPath(path))));
1595
2058
  return this.queue(() => this.claimNow(who, label, paths, options));
@@ -1616,18 +2079,18 @@ var Engine = class {
1616
2079
  },
1617
2080
  checkout: (entryId, side, emptyDir) => this.queue(async () => {
1618
2081
  this.entry(entryId);
1619
- if (!existsSync2(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
1620
- const dest = resolve3(emptyDir);
2082
+ if (!existsSync3(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
2083
+ const dest = resolve6(emptyDir);
1621
2084
  if (isWithin(this.dir, dest))
1622
2085
  throw new Error(`Checkout writes outside the project only: ${dest}`);
1623
2086
  if ((await readdir3(dest).catch(missingIsEmpty)).length > 0)
1624
2087
  throw new Error(`Checkout writes into an empty folder only: ${dest}`);
1625
2088
  const files = manifestAround(this.log, entryId, side);
1626
2089
  try {
1627
- for (const [path, hash] of files) await this.blobs.writeTo(hash, join5(dest, path));
2090
+ for (const [path, hash] of files) await this.blobs.writeTo(hash, join7(dest, path));
1628
2091
  } catch (error) {
1629
2092
  for (const name of await readdir3(dest).catch(() => []))
1630
- await rm3(join5(dest, name), { recursive: true, force: true });
2093
+ await rm3(join7(dest, name), { recursive: true, force: true });
1631
2094
  throw error;
1632
2095
  }
1633
2096
  }),
@@ -1650,6 +2113,7 @@ var Engine = class {
1650
2113
  this.listeners.add(listener);
1651
2114
  return () => this.listeners.delete(listener);
1652
2115
  },
2116
+ checkpoint: () => this.queue(() => this.settle()),
1653
2117
  flush: () => this.queue(() => this.settleAll()),
1654
2118
  replacedAtPath: () => this.whereFolder() === "replaced",
1655
2119
  close: () => this.closing ??= (async () => {
@@ -1664,10 +2128,10 @@ var Engine = class {
1664
2128
  }
1665
2129
  };
1666
2130
  function isWithin(dir, path) {
1667
- let probe = resolve3(path);
1668
- while (!existsSync2(probe) && dirname4(probe) !== probe) probe = dirname4(probe);
1669
- const rel = relative2(realpathSync.native(dir), realpathSync.native(probe));
1670
- return rel === "" || rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute2(rel);
2131
+ let probe = resolve6(path);
2132
+ while (!existsSync3(probe) && dirname4(probe) !== probe) probe = dirname4(probe);
2133
+ const rel = relative4(realpathSync.native(dir), realpathSync.native(probe));
2134
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute5(rel);
1671
2135
  }
1672
2136
  function missingIsEmpty(error) {
1673
2137
  if (error.code === "ENOENT") return [];
@@ -1680,16 +2144,17 @@ var HistoryClosedError = class extends Error {
1680
2144
  }
1681
2145
  };
1682
2146
  async function openProjectHistory(options) {
1683
- if (!existsSync2(options.projectDir))
2147
+ if (!existsSync3(options.projectDir))
1684
2148
  throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
1685
2149
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
1686
2150
  const release = await takeHistoryOwnership(
1687
- join5(options.historyRoot, projectId),
2151
+ join7(options.historyRoot, projectId),
1688
2152
  options.ownerWaitMs ?? 5e3
1689
2153
  );
1690
2154
  try {
1691
- const blobs = await openBlobStore(join5(options.historyRoot, projectId, "blobs"));
1692
- const engine = new Engine(options, projectId, blobs);
2155
+ const blobs = await openBlobStore(join7(options.historyRoot, projectId, "blobs"));
2156
+ const closedWindow = options.closedWindowAfterOwnership?.() ?? options.closedWindow;
2157
+ const engine = new Engine({ ...options, closedWindow }, projectId, blobs);
1693
2158
  await engine.queue(async () => {
1694
2159
  await engine.start();
1695
2160
  engine.hearWrites();
@@ -1709,9 +2174,9 @@ async function openProjectHistory(options) {
1709
2174
  }
1710
2175
  }
1711
2176
  function readProjectHistory(options) {
1712
- const dir = resolve3(options.projectDir);
2177
+ const dir = resolve6(options.projectDir);
1713
2178
  const id = readId(dir);
1714
- const home = id && isRecordedFolder(join5(options.historyRoot, id), statSync3(dir)) ? join5(options.historyRoot, id) : null;
2179
+ const home = id && isRecordedFolder(join7(options.historyRoot, id), statSync4(dir)) ? join7(options.historyRoot, id) : null;
1715
2180
  const read = home && readLog(logFileIn(home), () => {
1716
2181
  });
1717
2182
  const log = withoutHiddenPaths(read || { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() });
@@ -1720,7 +2185,7 @@ function readProjectHistory(options) {
1720
2185
  peek: (point) => peekOf(log, point),
1721
2186
  readBlob: async (hash) => {
1722
2187
  if (!home) throw new Error("That is not a history blob.");
1723
- return readBlob(join5(home, "blobs"), hash);
2188
+ return readBlob(join7(home, "blobs"), hash);
1724
2189
  }
1725
2190
  };
1726
2191
  }
@@ -1750,6 +2215,8 @@ function historyCache(open) {
1750
2215
  }
1751
2216
 
1752
2217
  export {
2218
+ HistoryBusyError,
2219
+ registerRenderOutput,
1753
2220
  STUDIO_SIGNATURE_MANIFEST_PATHS,
1754
2221
  affectsProjectSignature,
1755
2222
  resolveProjectSignature,
@@ -1764,9 +2231,14 @@ export {
1764
2231
  identifyFileWrite,
1765
2232
  consumeFileWriteReceipt,
1766
2233
  HistoryIdError,
1767
- HistoryBusyError,
1768
2234
  listProjectHistories,
1769
2235
  pruneGoneProjectHistories,
2236
+ beginArtifactCapture,
2237
+ recordArtifact,
2238
+ recordArtifacts,
2239
+ forgetRecordedArtifacts,
2240
+ artifactDirectory,
2241
+ recordRenderSuccess,
1770
2242
  START,
1771
2243
  DEFAULT_HISTORY_ROOT,
1772
2244
  MAX_WINDOW_IDLE_MS,