@hyperframes/studio-server 0.8.158 → 0.8.160

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.
@@ -10,16 +10,16 @@ import { randomUUID as randomUUID7 } from "crypto";
10
10
  import { mkdir as mkdir2, readdir as readdir3, rm as rm3, rmdir, writeFile } from "fs/promises";
11
11
  import {
12
12
  existsSync as existsSync3,
13
- lstatSync as lstatSync3,
13
+ lstatSync as lstatSync4,
14
14
  statSync as statSync4,
15
- mkdirSync as mkdirSync4,
16
- readdirSync as readdirSync3,
17
- readFileSync as readFileSync9,
15
+ mkdirSync as mkdirSync5,
16
+ readdirSync as readdirSync4,
17
+ readFileSync as readFileSync10,
18
18
  realpathSync,
19
- rmSync as rmSync2
19
+ rmSync as rmSync3
20
20
  } from "fs";
21
21
  import { homedir } from "os";
22
- import { dirname as dirname4, isAbsolute as isAbsolute5, join as join7, relative as relative4, resolve as resolve6, sep as sep4 } from "path";
22
+ import { dirname as dirname5, isAbsolute as isAbsolute5, join as join8, relative as relative5, resolve as resolve7, sep as sep5 } from "path";
23
23
  import { replaceFileAtomically as replaceFileAtomically5 } from "@hyperframes/core/atomic-file";
24
24
 
25
25
  // src/helpers/fileVersion.ts
@@ -411,9 +411,10 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
411
411
 
412
412
  // src/history/blobStore.ts
413
413
  import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
414
- import { constants, createReadStream, renameSync } from "fs";
414
+ import { constants, createReadStream, createWriteStream, renameSync } from "fs";
415
415
  import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "fs/promises";
416
416
  import { dirname, join as join2 } from "path";
417
+ import { pipeline } from "stream/promises";
417
418
  import { atomicTempPath } from "@hyperframes/core/atomic-file";
418
419
  var BLOB_HASH = /^[0-9a-f]{64}$/;
419
420
  function blobPath(dir, hash) {
@@ -426,6 +427,14 @@ async function hashFile(path) {
426
427
  for await (const chunk of createReadStream(path)) hash.update(chunk);
427
428
  return hash.digest("hex");
428
429
  }
430
+ async function cloneOrStream(from, to) {
431
+ try {
432
+ await copyFile(from, to, constants.COPYFILE_FICLONE_FORCE);
433
+ } catch {
434
+ const { mode } = await stat(from);
435
+ await pipeline(createReadStream(from), createWriteStream(to, { mode }));
436
+ }
437
+ }
429
438
  async function cloneOrCopy(from, to, beforeReplace) {
430
439
  await mkdir(dirname(to), { recursive: true });
431
440
  const temp = atomicTempPath(to);
@@ -450,7 +459,10 @@ async function openBlobStore(dir) {
450
459
  async put(absPath) {
451
460
  const temp = join2(dir, `incoming-${randomUUID3()}`);
452
461
  await mkdir(dir, { recursive: true });
453
- await copyFile(absPath, temp, constants.COPYFILE_FICLONE);
462
+ await cloneOrStream(absPath, temp).catch(async (error) => {
463
+ await rm(temp, { force: true });
464
+ throw error;
465
+ });
454
466
  const hash = await hashFile(temp);
455
467
  if (sizes.has(hash)) {
456
468
  await rm(temp, { force: true });
@@ -704,8 +716,8 @@ function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs }
704
716
 
705
717
  // src/history/videoState.ts
706
718
  import { createHash as createHash5, randomUUID as randomUUID6 } from "crypto";
707
- import { createReadStream as createReadStream2, existsSync as existsSync2, readFileSync as readFileSync7, renameSync as renameSync3, statSync as statSync3 } from "fs";
708
- import { isAbsolute as isAbsolute4, join as join6, resolve as resolve5 } from "path";
719
+ import { createReadStream as createReadStream2, existsSync as existsSync2, readFileSync as readFileSync8, renameSync as renameSync3, statSync as statSync3 } from "fs";
720
+ import { isAbsolute as isAbsolute4, join as join7, resolve as resolve6 } from "path";
709
721
  import { replaceFileAtomically as replaceFileAtomically3 } from "@hyperframes/core/atomic-file";
710
722
 
711
723
  // src/history/artifactCleanup.ts
@@ -824,6 +836,253 @@ function finishArtifactCleanup(projectDir, state) {
824
836
  updateLatest(state);
825
837
  }
826
838
 
839
+ // src/history/snapshotRuns.ts
840
+ import {
841
+ closeSync as closeSync2,
842
+ constants as constants3,
843
+ fstatSync as fstatSync2,
844
+ lstatSync as lstatSync3,
845
+ openSync as openSync2,
846
+ mkdirSync as mkdirSync3,
847
+ readFileSync as readFileSync7,
848
+ readdirSync as readdirSync3,
849
+ rmSync as rmSync2,
850
+ ftruncateSync,
851
+ writeSync,
852
+ writeFileSync as writeFileSync4
853
+ } from "fs";
854
+ import { dirname as dirname3, join as join6, relative as relative4, resolve as resolve5, sep as sep4 } from "path";
855
+ var SNAPSHOT_RUNS_TO_KEEP = 10;
856
+ var SNAPSHOT_GRACE_MS = 24 * 60 * 60 * 1e3;
857
+ var OWNER = ".capture-owner.json";
858
+ var ownedMarkers = /* @__PURE__ */ new Map();
859
+ var UUID = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
860
+ var rootOf = (project) => join6(resolve5(project), ".hyperframes", "artifacts", "snapshots");
861
+ function ordinaryPath(project, path) {
862
+ const root = resolve5(project);
863
+ const rel = relative4(root, path);
864
+ if (rel === ".." || rel.startsWith(`..${sep4}`)) return false;
865
+ let current = root;
866
+ for (const part of rel.split(sep4).filter(Boolean)) {
867
+ current = join6(current, part);
868
+ try {
869
+ if (lstatSync3(current).isSymbolicLink()) return false;
870
+ } catch {
871
+ return false;
872
+ }
873
+ }
874
+ return true;
875
+ }
876
+ function createSnapshotParents(project, path) {
877
+ let parent = resolve5(project);
878
+ for (const part of relative4(parent, path).split(sep4).filter(Boolean)) {
879
+ parent = join6(parent, part);
880
+ try {
881
+ mkdirSync3(parent);
882
+ } catch (error) {
883
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
884
+ }
885
+ if (!ordinaryPath(project, parent)) throw new Error("Snapshot storage must not use symlinks.");
886
+ }
887
+ }
888
+ function ownSnapshotRun(project, path) {
889
+ const root = rootOf(project);
890
+ if (dirname3(path) !== root || !UUID.test(path.slice(root.length + 1))) return;
891
+ createSnapshotParents(project, root);
892
+ mkdirSync3(path);
893
+ const marker = join6(path, OWNER);
894
+ const fd = openSync2(marker, "wx");
895
+ try {
896
+ writeFileSync4(fd, JSON.stringify({ version: 1, pid: process.pid }));
897
+ ownedMarkers.set(marker, fstatSync2(fd));
898
+ } finally {
899
+ closeSync2(fd);
900
+ }
901
+ }
902
+ function missingFile(error) {
903
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
904
+ }
905
+ function retainedOutput(path) {
906
+ try {
907
+ lstatSync3(join6(path, ".user-output"));
908
+ return true;
909
+ } catch (error) {
910
+ return !missingFile(error);
911
+ }
912
+ }
913
+ function processIsAlive(pid) {
914
+ try {
915
+ process.kill(pid, 0);
916
+ return true;
917
+ } catch (error) {
918
+ return !(error instanceof Error && "code" in error && error.code === "ESRCH");
919
+ }
920
+ }
921
+ function ownerPid(owner) {
922
+ if (!owner || typeof owner !== "object" || !("pid" in owner)) return null;
923
+ const pid = owner.pid;
924
+ return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : null;
925
+ }
926
+ function ordinaryOwner(stat2) {
927
+ return stat2.isFile() && stat2.nlink === 1;
928
+ }
929
+ function sameOwnerFile(before, current) {
930
+ return current.dev === before.dev && current.ino === before.ino;
931
+ }
932
+ function openOwner(marker, access) {
933
+ const fd = openSync2(marker, access | (constants3.O_NOFOLLOW ?? 0) | (constants3.O_NONBLOCK ?? 0));
934
+ try {
935
+ const current = fstatSync2(fd);
936
+ const named = lstatSync3(marker);
937
+ if (!ordinaryOwner(current) || !ordinaryOwner(named) || !sameOwnerFile(named, current))
938
+ throw new Error("Snapshot owner changed while opening.");
939
+ return fd;
940
+ } catch (error) {
941
+ closeSync2(fd);
942
+ throw error;
943
+ }
944
+ }
945
+ function readOwner(marker) {
946
+ const fd = openOwner(marker, constants3.O_RDONLY);
947
+ try {
948
+ return JSON.parse(readFileSync7(fd, "utf8"));
949
+ } finally {
950
+ closeSync2(fd);
951
+ }
952
+ }
953
+ function releasedOwner(owner) {
954
+ return !!owner && typeof owner === "object" && "released" in owner && owner.released === true && ownerPid(owner) !== null;
955
+ }
956
+ function liveOwner(path) {
957
+ if (retainedOutput(path)) return true;
958
+ try {
959
+ const marker = join6(path, OWNER);
960
+ const owner = readOwner(marker);
961
+ if (releasedOwner(owner)) return false;
962
+ const pid = ownerPid(owner);
963
+ return pid === null || processIsAlive(pid);
964
+ } catch (error) {
965
+ return !missingFile(error);
966
+ }
967
+ }
968
+ function recordsOf(state) {
969
+ return [...Object.values(state.snapshots.revisions).flat(), ...state.snapshots.unversioned];
970
+ }
971
+ function containingRun(root, path) {
972
+ if (!path) return null;
973
+ const name = relative4(root, resolve5(path)).split(sep4)[0];
974
+ return name && UUID.test(name) ? join6(root, name) : null;
975
+ }
976
+ function keepContainingRun(keep, root, path) {
977
+ const run = containingRun(root, path);
978
+ if (run) keep.add(run);
979
+ }
980
+ function protectedRuns(root, state, records2) {
981
+ const keep = /* @__PURE__ */ new Set();
982
+ for (const revision of [state.currentRevision, ...state.renders?.retainedRevisions ?? []]) {
983
+ const latest2 = records2.filter((record) => record.revision === revision && record.status !== "cleaned").sort((a, b) => b.createdAt - a.createdAt)[0];
984
+ keepContainingRun(keep, root, latest2?.image);
985
+ }
986
+ const latest = records2.find((record) => record.id === state.snapshots.latest?.id);
987
+ keepContainingRun(keep, root, latest?.image);
988
+ for (const item of state.pendingArtifactCleanup ?? []) keepContainingRun(keep, root, item.path);
989
+ return keep;
990
+ }
991
+ function runsOnDisk(root) {
992
+ let entries;
993
+ try {
994
+ entries = readdirSync3(root, { withFileTypes: true });
995
+ } catch {
996
+ return [];
997
+ }
998
+ return entries.filter((entry) => entry.isDirectory() && UUID.test(entry.name)).flatMap((entry) => {
999
+ const path = join6(root, entry.name);
1000
+ try {
1001
+ const stat2 = lstatSync3(path);
1002
+ return [{ path, stat: stat2 }];
1003
+ } catch {
1004
+ return [];
1005
+ }
1006
+ }).sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs || a.path.localeCompare(b.path));
1007
+ }
1008
+ function planSnapshotCleanup(project, state, now = Date.now()) {
1009
+ const root = rootOf(project);
1010
+ if (!ordinaryPath(project, root)) return [];
1011
+ const records2 = recordsOf(state);
1012
+ const keep = protectedRuns(root, state, records2);
1013
+ const runs = runsOnDisk(root);
1014
+ for (const run of runs.slice(0, SNAPSHOT_RUNS_TO_KEEP)) keep.add(run.path);
1015
+ const pending = [];
1016
+ for (const { path, stat: stat2 } of runs) {
1017
+ if (keep.has(path) || now - stat2.mtimeMs < SNAPSHOT_GRACE_MS || liveOwner(path)) continue;
1018
+ try {
1019
+ const run = { path, dev: stat2.dev, ino: stat2.ino, mtimeMs: stat2.mtimeMs };
1020
+ if (!unchangedRun(project, run)) continue;
1021
+ pending.push(run);
1022
+ retireReferences(records2, root, path);
1023
+ } catch {
1024
+ }
1025
+ }
1026
+ return pending;
1027
+ }
1028
+ function retireReferences(records2, root, path) {
1029
+ for (const record of records2) {
1030
+ if (containingRun(root, record.image) !== path) continue;
1031
+ delete record.image;
1032
+ record.status = "cleaned";
1033
+ }
1034
+ }
1035
+ function unchangedRun(project, run) {
1036
+ if (!ordinaryPath(project, run.path)) return false;
1037
+ const current = lstatSync3(run.path);
1038
+ return current.isDirectory() && current.dev === run.dev && current.ino === run.ino && current.mtimeMs === run.mtimeMs;
1039
+ }
1040
+ function finishSnapshotCleanup(project, runs) {
1041
+ for (const run of runs) {
1042
+ try {
1043
+ if (!unchangedRun(project, run) || liveOwner(run.path)) continue;
1044
+ rmSync2(run.path, { recursive: true });
1045
+ } catch {
1046
+ }
1047
+ }
1048
+ }
1049
+ function releaseSnapshotRun(project, path) {
1050
+ const marker = join6(path, OWNER);
1051
+ const expected = ownedMarkers.get(marker);
1052
+ if (!expected) return;
1053
+ try {
1054
+ if (dirname3(path) !== rootOf(project) || !ordinaryPath(project, path)) return;
1055
+ const fd = openOwner(marker, constants3.O_RDWR);
1056
+ try {
1057
+ if (!sameOwnerFile(expected, fstatSync2(fd))) return;
1058
+ const owner = JSON.parse(readFileSync7(fd, "utf8"));
1059
+ if (ownerPid(owner) !== process.pid) return;
1060
+ ftruncateSync(fd, 0);
1061
+ writeSync(fd, JSON.stringify({ version: 1, pid: process.pid, released: true }), 0, "utf8");
1062
+ } finally {
1063
+ closeSync2(fd);
1064
+ }
1065
+ } catch {
1066
+ } finally {
1067
+ ownedMarkers.delete(marker);
1068
+ }
1069
+ }
1070
+ function retainSnapshotOutput(project, path) {
1071
+ const root = rootOf(project);
1072
+ const run = containingRun(root, path);
1073
+ if (!run) return;
1074
+ createSnapshotParents(project, resolve5(path));
1075
+ try {
1076
+ writeFileSync4(
1077
+ join6(run, ".user-output"),
1078
+ "Explicit --output; retained until manual cleanup.\n",
1079
+ { flag: "wx" }
1080
+ );
1081
+ } catch (error) {
1082
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
1083
+ }
1084
+ }
1085
+
827
1086
  // src/history/videoState.ts
828
1087
  function statePath(dir) {
829
1088
  const path = pinProjectRecord(dir, ".hyperframes/video-state.json");
@@ -843,7 +1102,7 @@ var emptyState = () => ({
843
1102
  });
844
1103
  function readState(dir) {
845
1104
  if (!existsSync2(statePath(dir))) return emptyState();
846
- const state = JSON.parse(readFileSync7(statePath(dir), "utf8"));
1105
+ const state = JSON.parse(readFileSync8(statePath(dir), "utf8"));
847
1106
  if (state.version !== 1 || !state.checks || !state.snapshots || !state.revisions || !state.files)
848
1107
  throw new Error("Unsupported video-state schema.");
849
1108
  return state;
@@ -868,7 +1127,7 @@ async function updateState(dir, update) {
868
1127
  }
869
1128
  }
870
1129
  async function writeHistoryState(projectDir, historyId, historyDirectory, entries, currentFiles, committedFiles) {
871
- const blob = (hash) => hash === null ? null : resolve5(historyDirectory, "blobs", hash.slice(0, 2), hash);
1130
+ const blob = (hash) => hash === null ? null : resolve6(historyDirectory, "blobs", hash.slice(0, 2), hash);
872
1131
  await updateState(projectDir, (state) => {
873
1132
  if (state.historyId && state.historyId !== historyId) {
874
1133
  state.checks = collection();
@@ -920,7 +1179,7 @@ async function fileHashes(dir) {
920
1179
  const files = {};
921
1180
  for (const { path } of listProjectFiles(dir).sort((a, b) => a.path.localeCompare(b.path))) {
922
1181
  const hash = createHash5("sha256");
923
- for await (const chunk of createReadStream2(join6(dir, path))) hash.update(chunk);
1182
+ for await (const chunk of createReadStream2(join7(dir, path))) hash.update(chunk);
924
1183
  files[path] = hash.digest("hex");
925
1184
  }
926
1185
  return files;
@@ -943,10 +1202,11 @@ async function recordArtifacts(projectDir, capture, artifactsToRecord) {
943
1202
  for (const artifact of artifactsToRecord) {
944
1203
  const path = artifact.kind === "check" ? artifact.report : artifact.image;
945
1204
  if (!path || !isAbsolute4(path)) throw new Error("Artifact needs an absolute output path.");
946
- hashes.set(artifact.id, createHash5("sha256").update(readFileSync7(path)).digest("hex"));
1205
+ hashes.set(artifact.id, createHash5("sha256").update(readFileSync8(path)).digest("hex"));
947
1206
  }
948
1207
  const after = await beginArtifactCapture(projectDir);
949
1208
  const unchanged = after.fingerprint === capture.fingerprint;
1209
+ let cleanup = [];
950
1210
  await updateState(projectDir, (state) => {
951
1211
  const revision = unchanged ? knownRevision(state, capture.revision) : null;
952
1212
  const attribution = attributionOf(unchanged, revision);
@@ -968,7 +1228,9 @@ async function recordArtifacts(projectDir, capture, artifactsToRecord) {
968
1228
  records2.push(record);
969
1229
  artifacts.latest = { revision, id: record.id };
970
1230
  }
1231
+ cleanup = planSnapshotCleanup(projectDir, state);
971
1232
  });
1233
+ finishSnapshotCleanup(projectDir, cleanup);
972
1234
  return records2;
973
1235
  }
974
1236
  function knownRevision(state, revision) {
@@ -996,7 +1258,7 @@ function listsOf(artifacts) {
996
1258
  return [...Object.values(artifacts.revisions), artifacts.unversioned];
997
1259
  }
998
1260
  async function forgetRecordedArtifacts(projectDir, paths) {
999
- const gone = new Set(paths.map((path) => resolve5(path)));
1261
+ const gone = new Set(paths.map((path) => resolve6(path)));
1000
1262
  if (gone.size === 0) return;
1001
1263
  await updateState(projectDir, (state) => {
1002
1264
  for (const artifacts of [state.checks, state.snapshots]) {
@@ -1004,7 +1266,7 @@ async function forgetRecordedArtifacts(projectDir, paths) {
1004
1266
  for (let i = list.length - 1; i >= 0; i--) {
1005
1267
  const record = list[i];
1006
1268
  const path = record.image ?? record.report;
1007
- if (path && gone.has(resolve5(path))) list.splice(i, 1);
1269
+ if (path && gone.has(resolve6(path))) list.splice(i, 1);
1008
1270
  }
1009
1271
  }
1010
1272
  if (artifacts.latest && !listsOf(artifacts).some(
@@ -1016,7 +1278,9 @@ async function forgetRecordedArtifacts(projectDir, paths) {
1016
1278
  });
1017
1279
  }
1018
1280
  function artifactDirectory(projectDir, kind) {
1019
- return join6(resolve5(projectDir), ".hyperframes", "artifacts", kind, randomUUID6());
1281
+ const path = join7(resolve6(projectDir), ".hyperframes", "artifacts", kind, randomUUID6());
1282
+ if (kind === "snapshots") ownSnapshotRun(projectDir, path);
1283
+ return path;
1020
1284
  }
1021
1285
  function retainRevision(retained, revision) {
1022
1286
  if (!revision) return [...retained];
@@ -1030,7 +1294,7 @@ async function recordRenderSuccess(projectDir, output, status = "completed", cap
1030
1294
  const record = {
1031
1295
  id: randomUUID6(),
1032
1296
  completedAt: Date.now(),
1033
- output: resolve5(output),
1297
+ output: resolve6(output),
1034
1298
  status
1035
1299
  };
1036
1300
  const unchanged = Boolean(capture && after?.fingerprint === capture.fingerprint);
@@ -1048,10 +1312,18 @@ async function recordRenderSuccess(projectDir, output, status = "completed", cap
1048
1312
  });
1049
1313
  return record;
1050
1314
  }
1315
+ async function finishSnapshotRun(projectDir, path) {
1316
+ releaseSnapshotRun(projectDir, path);
1317
+ let cleanup = [];
1318
+ await updateState(projectDir, (state) => {
1319
+ cleanup = planSnapshotCleanup(projectDir, state);
1320
+ });
1321
+ finishSnapshotCleanup(projectDir, cleanup);
1322
+ }
1051
1323
 
1052
1324
  // src/history/historyLog.ts
1053
- import { closeSync as closeSync2, constants as constants3, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync8, writeSync } from "fs";
1054
- import { dirname as dirname3 } from "path";
1325
+ import { closeSync as closeSync3, constants as constants4, mkdirSync as mkdirSync4, openSync as openSync3, readFileSync as readFileSync9, writeSync as writeSync2 } from "fs";
1326
+ import { dirname as dirname4 } from "path";
1055
1327
  import { replaceFileAtomically as replaceFileAtomically4 } from "@hyperframes/core/atomic-file";
1056
1328
  var START = "start";
1057
1329
  function parseRecord(line) {
@@ -1064,7 +1336,7 @@ function parseRecord(line) {
1064
1336
  function readLog(file, onUnreadable) {
1065
1337
  let text;
1066
1338
  try {
1067
- text = readFileSync8(file, "utf-8");
1339
+ text = readFileSync9(file, "utf-8");
1068
1340
  } catch {
1069
1341
  return null;
1070
1342
  }
@@ -1088,16 +1360,16 @@ function applyRecord(log, record) {
1088
1360
  function saveRecord(file, log, record) {
1089
1361
  let fd;
1090
1362
  try {
1091
- fd = openSync2(file, constants3.O_WRONLY | constants3.O_APPEND);
1363
+ fd = openSync3(file, constants4.O_WRONLY | constants4.O_APPEND);
1092
1364
  } catch (error) {
1093
1365
  if (error.code !== "ENOENT") throw error;
1094
1366
  return writeLog(file, log);
1095
1367
  }
1096
1368
  try {
1097
- writeSync(fd, `${JSON.stringify(record)}
1369
+ writeSync2(fd, `${JSON.stringify(record)}
1098
1370
  `);
1099
1371
  } finally {
1100
- closeSync2(fd);
1372
+ closeSync3(fd);
1101
1373
  }
1102
1374
  }
1103
1375
  function baselineRecord(log) {
@@ -1113,7 +1385,7 @@ function writeLog(file, log) {
1113
1385
  ...log.entries.map((entry) => ({ type: "entry", entry })),
1114
1386
  ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
1115
1387
  ];
1116
- mkdirSync3(dirname3(file), { recursive: true });
1388
+ mkdirSync4(dirname4(file), { recursive: true });
1117
1389
  replaceFileAtomically4(file, records2.map((r) => `${JSON.stringify(r)}
1118
1390
  `).join(""), 420);
1119
1391
  }
@@ -1186,7 +1458,7 @@ function referencedHashes(log, current) {
1186
1458
  }
1187
1459
 
1188
1460
  // src/history/projectHistory.ts
1189
- var DEFAULT_HISTORY_ROOT = join7(homedir(), ".cache", "hyperframes", "history");
1461
+ var DEFAULT_HISTORY_ROOT = join8(homedir(), ".cache", "hyperframes", "history");
1190
1462
  var MAX_WINDOW_IDLE_MS = 10 * 6e4;
1191
1463
  var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
1192
1464
  var OUTSIDE = { kind: "outside", name: "Outside" };
@@ -1199,7 +1471,7 @@ function isHistoryPath(path) {
1199
1471
  if (KEPT_HIDDEN_PATHS.has(path)) return true;
1200
1472
  return !path.split("/").some((segment) => segment.startsWith("."));
1201
1473
  }
1202
- var logFileIn = (home) => join7(home, "log.jsonl");
1474
+ var logFileIn = (home) => join8(home, "log.jsonl");
1203
1475
  function listOf(log) {
1204
1476
  const undone = undoneIds(log.entries);
1205
1477
  return log.entries.map((entry) => ({
@@ -1239,20 +1511,20 @@ async function removeEmptyFolders(dir) {
1239
1511
  throw error;
1240
1512
  }
1241
1513
  for (const entry of entries)
1242
- if (entry.isDirectory()) await removeEmptyFolders(join7(dir, entry.name));
1514
+ if (entry.isDirectory()) await removeEmptyFolders(join8(dir, entry.name));
1243
1515
  await rmdir(dir);
1244
1516
  }
1245
1517
  function fileLeftIn(dir, at, deleted) {
1246
- for (const entry of readdirSync3(dir, { withFileTypes: true })) {
1518
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
1247
1519
  const path = `${at}/${entry.name}`;
1248
- const left = entry.isDirectory() ? fileLeftIn(join7(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
1520
+ const left = entry.isDirectory() ? fileLeftIn(join8(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
1249
1521
  if (left) return left;
1250
1522
  }
1251
1523
  return void 0;
1252
1524
  }
1253
1525
  function ignoresCase(dir) {
1254
- const self = statSync4(join7(dir, ID_PATH), { throwIfNoEntry: false });
1255
- const other = statSync4(join7(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
1526
+ const self = statSync4(join8(dir, ID_PATH), { throwIfNoEntry: false });
1527
+ const other = statSync4(join8(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
1256
1528
  return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
1257
1529
  }
1258
1530
  var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
@@ -1289,8 +1561,8 @@ var Engine = class {
1289
1561
  this.options = options;
1290
1562
  this.projectId = projectId;
1291
1563
  this.blobs = blobs;
1292
- this.dir = resolve6(options.projectDir);
1293
- this.home = join7(options.historyRoot, projectId);
1564
+ this.dir = resolve7(options.projectDir);
1565
+ this.home = join8(options.historyRoot, projectId);
1294
1566
  this.foldsCase = ignoresCase(this.dir);
1295
1567
  const folder = statSync4(this.dir, { throwIfNoEntry: false });
1296
1568
  if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
@@ -1370,7 +1642,7 @@ var Engine = class {
1370
1642
  persistLog(record) {
1371
1643
  if (record) saveRecord(this.logFile, this.log, record);
1372
1644
  else writeLog(this.logFile, this.log);
1373
- if (!existsSync3(join7(this.home, "project.json")))
1645
+ if (!existsSync3(join8(this.home, "project.json")))
1374
1646
  recordProject(this.home, this.dir, this.folder);
1375
1647
  }
1376
1648
  replaced() {
@@ -1409,7 +1681,7 @@ var Engine = class {
1409
1681
  if (!after || !affectsProjectSignature(realDir, absPath)) return;
1410
1682
  const replaced = hashOfVersion(fileContentVersion(bytes));
1411
1683
  if (!replaced || replaced === after) return;
1412
- const path = relative4(realDir, absPath).split(sep4).join("/");
1684
+ const path = relative5(realDir, absPath).split(sep5).join("/");
1413
1685
  if (!isHistoryPath(path)) return;
1414
1686
  this.overwritten.set(
1415
1687
  path,
@@ -1421,7 +1693,7 @@ var Engine = class {
1421
1693
  });
1422
1694
  }
1423
1695
  async storeBytes(bytes) {
1424
- const staged = join7(this.home, `overwrote-${randomUUID7()}`);
1696
+ const staged = join8(this.home, `overwrote-${randomUUID7()}`);
1425
1697
  await mkdir2(this.home, { recursive: true });
1426
1698
  await writeFile(staged, bytes);
1427
1699
  try {
@@ -1475,9 +1747,9 @@ var Engine = class {
1475
1747
  this.saveAdopting();
1476
1748
  }
1477
1749
  saveAdopting() {
1478
- const file = join7(this.home, "adopting.json");
1479
- if (!this.adopting.size) return rmSync2(file, { force: true });
1480
- mkdirSync4(this.home, { recursive: true });
1750
+ const file = join8(this.home, "adopting.json");
1751
+ if (!this.adopting.size) return rmSync3(file, { force: true });
1752
+ mkdirSync5(this.home, { recursive: true });
1481
1753
  replaceFileAtomically5(file, JSON.stringify([...this.adopting.keys()]), 420);
1482
1754
  }
1483
1755
  async adopt(file, sweptAt) {
@@ -1511,7 +1783,7 @@ var Engine = class {
1511
1783
  }
1512
1784
  readStatCache() {
1513
1785
  try {
1514
- const files = JSON.parse(readFileSync9(join7(this.home, "stat.json"), "utf-8"));
1786
+ const files = JSON.parse(readFileSync10(join8(this.home, "stat.json"), "utf-8"));
1515
1787
  return new Map(Object.entries(files));
1516
1788
  } catch {
1517
1789
  return /* @__PURE__ */ new Map();
@@ -1520,7 +1792,7 @@ var Engine = class {
1520
1792
  readAdopting() {
1521
1793
  try {
1522
1794
  return new Set(
1523
- JSON.parse(readFileSync9(join7(this.home, "adopting.json"), "utf-8"))
1795
+ JSON.parse(readFileSync10(join8(this.home, "adopting.json"), "utf-8"))
1524
1796
  );
1525
1797
  } catch {
1526
1798
  return /* @__PURE__ */ new Set();
@@ -1528,8 +1800,8 @@ var Engine = class {
1528
1800
  }
1529
1801
  saveStatCache() {
1530
1802
  const files = JSON.stringify(Object.fromEntries(this.tracked));
1531
- mkdirSync4(this.home, { recursive: true });
1532
- replaceFileAtomically5(join7(this.home, "stat.json"), files, 420);
1803
+ mkdirSync5(this.home, { recursive: true });
1804
+ replaceFileAtomically5(join8(this.home, "stat.json"), files, 420);
1533
1805
  }
1534
1806
  /**
1535
1807
  * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
@@ -1571,7 +1843,7 @@ var Engine = class {
1571
1843
  /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
1572
1844
  async storeIfPresent(path) {
1573
1845
  try {
1574
- return await this.blobs.put(join7(this.dir, path));
1846
+ return await this.blobs.put(join8(this.dir, path));
1575
1847
  } catch (error) {
1576
1848
  if (error.code === "ENOENT") return null;
1577
1849
  throw error;
@@ -1645,7 +1917,7 @@ var Engine = class {
1645
1917
  return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
1646
1918
  }
1647
1919
  logPath(path) {
1648
- return relative4(this.dir, resolve6(this.dir, path)).split(sep4).join("/");
1920
+ return relative5(this.dir, resolve7(this.dir, path)).split(sep5).join("/");
1649
1921
  }
1650
1922
  /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
1651
1923
  async takeClaimed(who, paths, overwrote) {
@@ -1806,9 +2078,9 @@ var Engine = class {
1806
2078
  }
1807
2079
  /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
1808
2080
  noteChange(path) {
1809
- const absolute = resolve6(this.dir, path);
2081
+ const absolute = resolve7(this.dir, path);
1810
2082
  if (this.notedTimer || !affectsProjectSignature(this.dir, absolute)) return;
1811
- if (!isHistoryPath(relative4(this.dir, absolute).split(sep4).join("/"))) return;
2083
+ if (!isHistoryPath(relative5(this.dir, absolute).split(sep5).join("/"))) return;
1812
2084
  this.notedTimer = setTimeout(() => {
1813
2085
  this.notedTimer = null;
1814
2086
  this.background(() => this.sweep());
@@ -1884,7 +2156,7 @@ var Engine = class {
1884
2156
  const parts = path.split("/");
1885
2157
  for (let depth = 1; depth <= parts.length; depth += 1) {
1886
2158
  const at = parts.slice(0, depth).join("/");
1887
- const stat2 = lstatSync3(join7(this.dir, at), { throwIfNoEntry: false });
2159
+ const stat2 = lstatSync4(join8(this.dir, at), { throwIfNoEntry: false });
1888
2160
  if (!stat2) return void 0;
1889
2161
  if (at === path || !stat2.isDirectory()) return { at, stat: stat2 };
1890
2162
  }
@@ -1896,8 +2168,8 @@ var Engine = class {
1896
2168
  if (!standing) return void 0;
1897
2169
  if (standing.stat.isSymbolicLink()) return standing.at;
1898
2170
  if (standing.at === path && !standing.stat.isDirectory()) return void 0;
1899
- const real = realpathSync.native(join7(this.dir, standing.at));
1900
- const at = relative4(realpathSync.native(this.dir), real).split(sep4).join("/");
2171
+ const real = realpathSync.native(join8(this.dir, standing.at));
2172
+ const at = relative5(realpathSync.native(this.dir), real).split(sep5).join("/");
1901
2173
  if (standing.at !== path) return deleted.has(at) ? void 0 : at;
1902
2174
  return fileLeftIn(real, at, deleted);
1903
2175
  }
@@ -1921,7 +2193,7 @@ var Engine = class {
1921
2193
  /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
1922
2194
  async writeProjectFile(path, hash) {
1923
2195
  this.assertWritable();
1924
- const absPath = join7(this.dir, path);
2196
+ const absPath = join8(this.dir, path);
1925
2197
  const { writeToken } = this;
1926
2198
  if (writeToken) {
1927
2199
  const version = hash === null ? DELETED_VERSION : hashVersion(hash);
@@ -1929,7 +2201,7 @@ var Engine = class {
1929
2201
  }
1930
2202
  if (hash === null) {
1931
2203
  this.assertWritable();
1932
- rmSync2(absPath, { force: true });
2204
+ rmSync3(absPath, { force: true });
1933
2205
  } else {
1934
2206
  await removeEmptyFolders(absPath);
1935
2207
  await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
@@ -2065,17 +2337,17 @@ var Engine = class {
2065
2337
  checkout: (entryId, side, emptyDir) => this.queue(async () => {
2066
2338
  this.entry(entryId);
2067
2339
  if (!existsSync3(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
2068
- const dest = resolve6(emptyDir);
2340
+ const dest = resolve7(emptyDir);
2069
2341
  if (isWithin(this.dir, dest))
2070
2342
  throw new Error(`Checkout writes outside the project only: ${dest}`);
2071
2343
  if ((await readdir3(dest).catch(missingIsEmpty)).length > 0)
2072
2344
  throw new Error(`Checkout writes into an empty folder only: ${dest}`);
2073
2345
  const files = manifestAround(this.log, entryId, side);
2074
2346
  try {
2075
- for (const [path, hash] of files) await this.blobs.writeTo(hash, join7(dest, path));
2347
+ for (const [path, hash] of files) await this.blobs.writeTo(hash, join8(dest, path));
2076
2348
  } catch (error) {
2077
2349
  for (const name of await readdir3(dest).catch(() => []))
2078
- await rm3(join7(dest, name), { recursive: true, force: true });
2350
+ await rm3(join8(dest, name), { recursive: true, force: true });
2079
2351
  throw error;
2080
2352
  }
2081
2353
  }),
@@ -2113,10 +2385,10 @@ var Engine = class {
2113
2385
  }
2114
2386
  };
2115
2387
  function isWithin(dir, path) {
2116
- let probe = resolve6(path);
2117
- while (!existsSync3(probe) && dirname4(probe) !== probe) probe = dirname4(probe);
2118
- const rel = relative4(realpathSync.native(dir), realpathSync.native(probe));
2119
- return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute5(rel);
2388
+ let probe = resolve7(path);
2389
+ while (!existsSync3(probe) && dirname5(probe) !== probe) probe = dirname5(probe);
2390
+ const rel = relative5(realpathSync.native(dir), realpathSync.native(probe));
2391
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep5}`) && !isAbsolute5(rel);
2120
2392
  }
2121
2393
  function missingIsEmpty(error) {
2122
2394
  if (error.code === "ENOENT") return [];
@@ -2133,11 +2405,11 @@ async function openProjectHistory(options) {
2133
2405
  throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
2134
2406
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
2135
2407
  const release = await takeHistoryOwnership(
2136
- join7(options.historyRoot, projectId),
2408
+ join8(options.historyRoot, projectId),
2137
2409
  options.ownerWaitMs ?? 5e3
2138
2410
  );
2139
2411
  try {
2140
- const blobs = await openBlobStore(join7(options.historyRoot, projectId, "blobs"));
2412
+ const blobs = await openBlobStore(join8(options.historyRoot, projectId, "blobs"));
2141
2413
  const closedWindow = options.closedWindowAfterOwnership?.() ?? options.closedWindow;
2142
2414
  const engine = new Engine({ ...options, closedWindow }, projectId, blobs);
2143
2415
  await engine.queue(async () => {
@@ -2159,9 +2431,9 @@ async function openProjectHistory(options) {
2159
2431
  }
2160
2432
  }
2161
2433
  function readProjectHistory(options) {
2162
- const dir = resolve6(options.projectDir);
2434
+ const dir = resolve7(options.projectDir);
2163
2435
  const id = readId(dir);
2164
- const home = id && isRecordedFolder(join7(options.historyRoot, id), statSync4(dir)) ? join7(options.historyRoot, id) : null;
2436
+ const home = id && isRecordedFolder(join8(options.historyRoot, id), statSync4(dir)) ? join8(options.historyRoot, id) : null;
2165
2437
  const read = home && readLog(logFileIn(home), () => {
2166
2438
  });
2167
2439
  const log = withoutHiddenPaths(read || { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() });
@@ -2170,7 +2442,7 @@ function readProjectHistory(options) {
2170
2442
  peek: (point) => peekOf(log, point),
2171
2443
  readBlob: async (hash) => {
2172
2444
  if (!home) throw new Error("That is not a history blob.");
2173
- return readBlob(join7(home, "blobs"), hash);
2445
+ return readBlob(join8(home, "blobs"), hash);
2174
2446
  }
2175
2447
  };
2176
2448
  }
@@ -2218,12 +2490,14 @@ export {
2218
2490
  HistoryIdError,
2219
2491
  listProjectHistories,
2220
2492
  pruneGoneProjectHistories,
2493
+ retainSnapshotOutput,
2221
2494
  beginArtifactCapture,
2222
2495
  recordArtifact,
2223
2496
  recordArtifacts,
2224
2497
  forgetRecordedArtifacts,
2225
2498
  artifactDirectory,
2226
2499
  recordRenderSuccess,
2500
+ finishSnapshotRun,
2227
2501
  START,
2228
2502
  DEFAULT_HISTORY_ROOT,
2229
2503
  MAX_WINDOW_IDLE_MS,
@@ -130,7 +130,11 @@ declare function recordArtifacts(projectDir: string, capture: ArtifactCapture, a
130
130
  declare function forgetRecordedArtifacts(projectDir: string, paths: readonly string[]): Promise<void>;
131
131
  declare function artifactDirectory(projectDir: string, kind: "checks" | "snapshots"): string;
132
132
  declare function recordRenderSuccess(projectDir: string, output: string, status?: RenderRecord["status"], capture?: ArtifactCapture): Promise<RenderRecord>;
133
+ declare function finishSnapshotRun(projectDir: string, path: string): Promise<void>;
133
134
 
134
135
  declare function registerRenderOutput(projectDir: string, output: string): Promise<void>;
135
136
 
136
- export { type ArtifactCapture, type ArtifactInput, type ArtifactRecord, HistoryBusyError, HistoryIdError, HistoryTarget, HistoryWho, ProjectHistory, type ProjectHistoryRecord, type PrunedHistory, type RenderRecord, type VideoState, artifactDirectory, beginArtifactCapture, forgetRecordedArtifacts, historyCache, listProjectHistories, pruneGoneProjectHistories, recordArtifact, recordArtifacts, recordRenderSuccess, registerRenderOutput };
137
+ /** Explicit outputs remain user evidence even when reusing a managed UUID folder. */
138
+ declare function retainSnapshotOutput(project: string, path: string): void;
139
+
140
+ export { type ArtifactCapture, type ArtifactInput, type ArtifactRecord, HistoryBusyError, HistoryIdError, HistoryTarget, HistoryWho, ProjectHistory, type ProjectHistoryRecord, type PrunedHistory, type RenderRecord, type VideoState, artifactDirectory, beginArtifactCapture, finishSnapshotRun, forgetRecordedArtifacts, historyCache, listProjectHistories, pruneGoneProjectHistories, recordArtifact, recordArtifacts, recordRenderSuccess, registerRenderOutput, retainSnapshotOutput };
@@ -8,6 +8,7 @@ import {
8
8
  UNDO_MODES,
9
9
  artifactDirectory,
10
10
  beginArtifactCapture,
11
+ finishSnapshotRun,
11
12
  forgetRecordedArtifacts,
12
13
  historyCache,
13
14
  listProjectHistories,
@@ -17,8 +18,9 @@ import {
17
18
  recordArtifact,
18
19
  recordArtifacts,
19
20
  recordRenderSuccess,
20
- registerRenderOutput
21
- } from "../chunk-66HD2HFT.js";
21
+ registerRenderOutput,
22
+ retainSnapshotOutput
23
+ } from "../chunk-SZF3L5MT.js";
22
24
  import "../chunk-K56J3CL7.js";
23
25
  import "../chunk-SVQFE44C.js";
24
26
  import "../chunk-L3HJVTQE.js";
@@ -32,6 +34,7 @@ export {
32
34
  UNDO_MODES,
33
35
  artifactDirectory,
34
36
  beginArtifactCapture,
37
+ finishSnapshotRun,
35
38
  forgetRecordedArtifacts,
36
39
  historyCache,
37
40
  listProjectHistories,
@@ -41,5 +44,6 @@ export {
41
44
  recordArtifact,
42
45
  recordArtifacts,
43
46
  recordRenderSuccess,
44
- registerRenderOutput
47
+ registerRenderOutput,
48
+ retainSnapshotOutput
45
49
  };
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
2
2
  import { S as StudioApiAdapter, P as PreviewDocumentStore, M as MediaProcessingJobState } from './mediaProxyPreview-J8vlAvrx.js';
3
3
  export { L as LintResult, a as PreviewApiAdapter, R as RenderJobState, b as ResolvedProject, c as StudioSelectionResponse, d as StudioSelectionSnapshot, e as StudioSelectionTextField } from './mediaProxyPreview-J8vlAvrx.js';
4
4
  export { C as ClosedWindow, D as DEFAULT_HISTORY_ROOT, S as HISTORY_START, H as HistoryClosedError, a as HistoryEntry, b as HistoryEntrySide, c as HistoryFileChange, d as HistoryListItem, e as HistoryResult, f as HistoryTarget, g as HistoryWho, h as HistoryWindow, M as MAX_WINDOW_IDLE_MS, P as ProjectHistory, i as ProjectHistoryOptions, j as ProjectHistoryView, U as UNDO_MODES, k as UndoMode, o as openProjectHistory, r as readProjectHistory } from './projectHistory-C5dpCjPU.js';
5
- export { ArtifactCapture, ArtifactInput, ArtifactRecord, HistoryBusyError, HistoryIdError, ProjectHistoryRecord, PrunedHistory, RenderRecord, VideoState, artifactDirectory, beginArtifactCapture, forgetRecordedArtifacts, historyCache, listProjectHistories, pruneGoneProjectHistories, recordArtifact, recordArtifacts, recordRenderSuccess, registerRenderOutput } from './history/index.js';
5
+ export { ArtifactCapture, ArtifactInput, ArtifactRecord, HistoryBusyError, HistoryIdError, ProjectHistoryRecord, PrunedHistory, RenderRecord, VideoState, artifactDirectory, beginArtifactCapture, finishSnapshotRun, forgetRecordedArtifacts, historyCache, listProjectHistories, pruneGoneProjectHistories, recordArtifact, recordArtifacts, recordRenderSuccess, registerRenderOutput, retainSnapshotOutput } from './history/index.js';
6
6
  export { CACHE_DIR_NAME as PROXY_CACHE_DIR_NAME } from './helpers/proxyTranscoder.js';
7
7
  export { walkDir } from './helpers/safePath.js';
8
8
  export { stampProjectHfIds } from './helpers/hfIdPersist.js';
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  createProjectSignature,
44
44
  createWriteToken,
45
45
  fileContentVersion,
46
+ finishSnapshotRun,
46
47
  forgetRecordedArtifacts,
47
48
  historyCache,
48
49
  identifyFileWrite,
@@ -57,8 +58,9 @@ import {
57
58
  registerRenderOutput,
58
59
  resolveProjectAndSignature,
59
60
  resolveProjectSignature,
61
+ retainSnapshotOutput,
60
62
  settledFileTag
61
- } from "./chunk-66HD2HFT.js";
63
+ } from "./chunk-SZF3L5MT.js";
62
64
  import {
63
65
  clearElementScreenshotIsolation,
64
66
  getElementScreenshotClip
@@ -6754,6 +6756,7 @@ export {
6754
6756
  duplicateElementInHtml,
6755
6757
  fileContentVersion,
6756
6758
  findTargetElement,
6759
+ finishSnapshotRun,
6757
6760
  forgetRecordedArtifacts,
6758
6761
  getElementScreenshotClip,
6759
6762
  getMimeType,
@@ -6772,6 +6775,7 @@ export {
6772
6775
  registerRenderOutput,
6773
6776
  removeElementFromHtml,
6774
6777
  requestSubPath,
6778
+ retainSnapshotOutput,
6775
6779
  settledFileTag,
6776
6780
  splitElementInHtml,
6777
6781
  stampProjectHfIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio-server",
3
- "version": "0.8.158",
3
+ "version": "0.8.160",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/heygen-com/hyperframes",
@@ -85,8 +85,8 @@
85
85
  "postcss": "^8.5.8",
86
86
  "postcss-selector-parser": "^7.1.2",
87
87
  "sharp": "^0.35.0",
88
- "@hyperframes/core": "0.8.158",
89
- "@hyperframes/parsers": "0.8.158"
88
+ "@hyperframes/core": "0.8.160",
89
+ "@hyperframes/parsers": "0.8.160"
90
90
  },
91
91
  "devDependencies": {
92
92
  "@types/node": "^25.0.10",