@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.
- package/dist/{chunk-3RUFN2CK.js → chunk-6RICWA3B.js} +1 -1
- package/dist/{chunk-Y7V6FJHS.js → chunk-CW5G6XXR.js} +676 -204
- package/dist/{chunk-EJC7TX3O.js → chunk-FKJBNFWA.js} +1 -1
- package/dist/{chunk-Z5UJMFO4.js → chunk-JBFOY5SI.js} +23 -8
- package/dist/{chunk-XKBQOQFZ.js → chunk-JTPV2T3F.js} +2 -2
- package/dist/{chunk-M62HDRLX.js → chunk-K56J3CL7.js} +7 -1
- package/dist/{chunk-VMT4OC5B.js → chunk-KR3B6IJC.js} +1 -1
- package/dist/{chunk-ZNRTEXK2.js → chunk-RHYH6YOX.js} +2 -2
- package/dist/helpers/hfIdPersist.js +2 -2
- package/dist/helpers/loudness.js +2 -2
- package/dist/helpers/mediaCodecMap.js +2 -2
- package/dist/helpers/mediaProxyPreview.d.ts +2 -2
- package/dist/helpers/mediaProxyPreview.js +4 -4
- package/dist/helpers/proxyTranscoder.js +3 -3
- package/dist/helpers/safePath.d.ts +3 -1
- package/dist/helpers/safePath.js +3 -1
- package/dist/helpers/sourceMutation.js +1 -1
- package/dist/history/index.d.ts +90 -3
- package/dist/history/index.js +18 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +59 -10
- package/dist/{mediaProxyPreview-BR7yXz2M.d.ts → mediaProxyPreview-DB1EEdxc.d.ts} +1 -1
- package/dist/{projectHistory-CyTVYz2y.d.ts → projectHistory-C5dpCjPU.d.ts} +13 -1
- package/package.json +3 -3
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
2
|
isSafePath,
|
|
3
3
|
mkdirWithinProject,
|
|
4
|
+
pinProjectRecord,
|
|
4
5
|
realFilePath
|
|
5
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-K56J3CL7.js";
|
|
6
7
|
|
|
7
8
|
// src/history/projectHistory.ts
|
|
8
|
-
import { randomUUID as
|
|
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
|
|
12
|
-
lstatSync as
|
|
13
|
-
statSync as
|
|
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
|
|
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
|
|
22
|
-
import { replaceFileAtomically as
|
|
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 =
|
|
142
|
-
if (relativePath === "" || relativePath.startsWith("..") ||
|
|
275
|
+
const relativePath = relative2(resolve2(projectDir), resolve2(changedPath));
|
|
276
|
+
if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath) || isAtomicTempPath(relativePath)) {
|
|
143
277
|
return false;
|
|
144
278
|
}
|
|
145
|
-
|
|
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 =
|
|
153
|
-
return childRelativePath === "" || !childRelativePath.startsWith("..") && !
|
|
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
|
|
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
|
-
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
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 =
|
|
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(
|
|
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 =
|
|
373
|
+
const normalizedProjectDir = resolve2(projectDir);
|
|
236
374
|
return collectProjectFiles(normalizedProjectDir).map((entry) => ({
|
|
237
|
-
path:
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
323
|
-
sizes.set(hash, (await stat(
|
|
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 =
|
|
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
|
|
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
|
|
368
|
-
import { existsSync, mkdirSync, readFileSync as
|
|
369
|
-
import { basename, join as
|
|
370
|
-
import { replaceFileAtomically } from "@hyperframes/core/atomic-file";
|
|
371
|
-
var ID_PATH =
|
|
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 =
|
|
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(
|
|
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 =
|
|
546
|
+
const dir = resolve3(projectDir);
|
|
409
547
|
const folder = statSync(dir);
|
|
410
548
|
let id = readId(dir);
|
|
411
|
-
if (!id && existsSync(
|
|
549
|
+
if (!id && existsSync(join3(dir, ID_PATH)))
|
|
412
550
|
throw new HistoryIdError(
|
|
413
|
-
`${
|
|
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(
|
|
416
|
-
id =
|
|
417
|
-
mkdirWithinProject(dir,
|
|
418
|
-
|
|
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(
|
|
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
|
-
|
|
433
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1132
|
+
replaceFileAtomically4(file, records2.map((r) => `${JSON.stringify(r)}
|
|
729
1133
|
`).join(""), 420);
|
|
730
1134
|
}
|
|
731
|
-
function applyEntry(
|
|
1135
|
+
function applyEntry(manifest2, entry, side) {
|
|
732
1136
|
for (const file of entry.files) {
|
|
733
1137
|
const hash = file[side];
|
|
734
|
-
if (hash === null)
|
|
735
|
-
else
|
|
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
|
|
1146
|
+
const manifest2 = new Map(log.baseline);
|
|
743
1147
|
for (const entry of log.entries) {
|
|
744
|
-
applyEntry(
|
|
745
|
-
if (entry.id === id) return
|
|
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 =
|
|
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) =>
|
|
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(
|
|
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(
|
|
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 =
|
|
866
|
-
const other =
|
|
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 =
|
|
895
|
-
this.home =
|
|
1307
|
+
this.dir = resolve6(options.projectDir);
|
|
1308
|
+
this.home = join7(options.historyRoot, projectId);
|
|
896
1309
|
this.foldsCase = ignoresCase(this.dir);
|
|
897
|
-
const folder =
|
|
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(
|
|
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 =
|
|
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 (!
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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 =
|
|
1824
|
+
const absolute = resolve6(this.dir, path);
|
|
1388
1825
|
if (this.notedTimer || !affectsProjectSignature(this.dir, absolute)) return;
|
|
1389
|
-
if (!isHistoryPath(
|
|
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 =
|
|
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(
|
|
1468
|
-
const at =
|
|
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 =
|
|
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, {
|
|
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 {
|
|
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(
|
|
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 (!
|
|
1620
|
-
const dest =
|
|
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,
|
|
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(
|
|
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 =
|
|
1668
|
-
while (!
|
|
1669
|
-
const rel =
|
|
1670
|
-
return rel === "" || rel !== ".." && !rel.startsWith(`..${
|
|
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 (!
|
|
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
|
-
|
|
2151
|
+
join7(options.historyRoot, projectId),
|
|
1688
2152
|
options.ownerWaitMs ?? 5e3
|
|
1689
2153
|
);
|
|
1690
2154
|
try {
|
|
1691
|
-
const blobs = await openBlobStore(
|
|
1692
|
-
const
|
|
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 =
|
|
2177
|
+
const dir = resolve6(options.projectDir);
|
|
1713
2178
|
const id = readId(dir);
|
|
1714
|
-
const home = id && isRecordedFolder(
|
|
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(
|
|
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,
|