narraeon 0.7.2 → 0.7.5
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/node/protocol/v1.js +8 -2
- package/dist/node/runtime/V1Runtime.js +12 -3
- package/dist/node/runtime/archive/WorldArchives.js +17 -5
- package/dist/node/runtime/content/ContentWorkspace.js +2 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +98 -165
- package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
- package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +104 -21
- package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
- package/dist/node/runtime/play/PlayCallChain.js +200 -374
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +312 -167
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +5 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +151 -108
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
- package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
- package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
- package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
- package/dist/node/server/createServer.js +44 -20
- package/dist/web/assets/index-9N9kO8kY.js +1408 -0
- package/dist/web/assets/index-BjxBYYQi.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BUCslLEK.css +0 -1
- package/dist/web/assets/index-DkamBH6c.js +0 -1408
package/dist/node/protocol/v1.js
CHANGED
|
@@ -4,10 +4,12 @@ import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
|
|
|
4
4
|
export const v1Protocol = "narraeon.runtime/v1";
|
|
5
5
|
export class V1ProtocolError extends Error {
|
|
6
6
|
code;
|
|
7
|
-
|
|
7
|
+
details;
|
|
8
|
+
constructor(code, message, options = {}) {
|
|
8
9
|
super(message, options);
|
|
9
10
|
this.name = "V1ProtocolError";
|
|
10
11
|
this.code = code;
|
|
12
|
+
this.details = options.details;
|
|
11
13
|
}
|
|
12
14
|
}
|
|
13
15
|
export function parseV1Envelope(value) {
|
|
@@ -37,7 +39,11 @@ const requiredFields = {
|
|
|
37
39
|
"world.package-scripts.set": { worldId: "string", enabled: "boolean" },
|
|
38
40
|
"content.gameplay.set": { packageId: "string", scheme: "string" },
|
|
39
41
|
"content.read": { packageId: "string" },
|
|
40
|
-
"content.replace": {
|
|
42
|
+
"content.replace": {
|
|
43
|
+
packageId: "string",
|
|
44
|
+
files: "array",
|
|
45
|
+
baseTreeRevision: "string",
|
|
46
|
+
},
|
|
41
47
|
"content.copy": { packageId: "string" },
|
|
42
48
|
"content.delete": { packageId: "string" },
|
|
43
49
|
"content.rename": { packageId: "string", name: "string" },
|
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { maxPortableContentArchiveBase64Characters, maxPortableContentArchiveBytes, } from "../protocol/contentTree.js";
|
|
8
8
|
import { V1ProtocolError, v1Protocol, } from "../protocol/v1.js";
|
|
9
9
|
import { defaultAppLocale, } from "../protocol/appPreferences.js";
|
|
10
|
-
import { ContentPackageImportError, ContentWorkspace, InvalidContentTreeError, } from "./content/ContentWorkspace.js";
|
|
10
|
+
import { ContentPackageImportError, ContentTreeConflictError, ContentWorkspace, InvalidContentTreeError, } from "./content/ContentWorkspace.js";
|
|
11
11
|
import { contentTreeFingerprint } from "./content/ContentTreeFingerprint.js";
|
|
12
12
|
import { inspectContentPackageCurrentTree } from "./content/FileNativeContentTree.js";
|
|
13
13
|
import { FileNativeModelHost } from "./model/FileNativeModelAdapters.js";
|
|
@@ -197,7 +197,7 @@ export class V1Runtime {
|
|
|
197
197
|
const chain = result;
|
|
198
198
|
if (chain?.status !== "interrupted" ||
|
|
199
199
|
typeof chain.lastFailure !== "string" ||
|
|
200
|
-
chain.
|
|
200
|
+
chain.lastCancelled === true)
|
|
201
201
|
return;
|
|
202
202
|
await this.#errorLog.recordPlayInterruption(request, {
|
|
203
203
|
message: chain.lastFailure,
|
|
@@ -215,6 +215,10 @@ export class V1Runtime {
|
|
|
215
215
|
: {}),
|
|
216
216
|
}, error);
|
|
217
217
|
}
|
|
218
|
+
/** Archive routes sit outside `handle`, so they record their own failures. */
|
|
219
|
+
async recordArchiveFailure(target, error) {
|
|
220
|
+
await this.#errorLog?.recordRequestFailure(target, error);
|
|
221
|
+
}
|
|
218
222
|
subscribeConversation(target, changed) {
|
|
219
223
|
return this.#conversationChanges.subscribe(`${target.kind}:${target.id}`, changed);
|
|
220
224
|
}
|
|
@@ -324,13 +328,18 @@ export class V1Runtime {
|
|
|
324
328
|
return this.#content.readCurrentTreeContentPackage(request.packageId);
|
|
325
329
|
case "content.replace":
|
|
326
330
|
try {
|
|
327
|
-
return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
|
|
331
|
+
return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files, { baseTreeRevision: request.baseTreeRevision });
|
|
328
332
|
}
|
|
329
333
|
catch (error) {
|
|
330
334
|
if (error instanceof InvalidContentTreeError)
|
|
331
335
|
throw new V1ProtocolError("invalid_request", error.message, {
|
|
332
336
|
cause: error,
|
|
333
337
|
});
|
|
338
|
+
if (error instanceof ContentTreeConflictError)
|
|
339
|
+
throw new V1ProtocolError("content_conflict", error.message, {
|
|
340
|
+
cause: error,
|
|
341
|
+
details: { current: error.current },
|
|
342
|
+
});
|
|
334
343
|
throw error;
|
|
335
344
|
}
|
|
336
345
|
case "content.copy":
|
|
@@ -108,7 +108,23 @@ export class WorldArchives {
|
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Confirms that `download` can serve this export without consuming it, so the
|
|
113
|
+
* browser can check before it starts the one-time download.
|
|
114
|
+
*/
|
|
115
|
+
async checkDownload(archiveId) {
|
|
116
|
+
const { root } = await this.#readExport(archiveId);
|
|
117
|
+
await stat(join(root, "archive.zip"));
|
|
118
|
+
}
|
|
111
119
|
async download(archiveId) {
|
|
120
|
+
const { root, metadata } = await this.#readExport(archiveId);
|
|
121
|
+
return {
|
|
122
|
+
metadata,
|
|
123
|
+
stream: createReadStream(join(root, "archive.zip")),
|
|
124
|
+
cleanup: () => rm(root, { recursive: true, force: true }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async #readExport(archiveId) {
|
|
112
128
|
identity(archiveId);
|
|
113
129
|
const root = join(this.#root, "exports", archiveId);
|
|
114
130
|
const metadata = JSON.parse(await readFile(join(root, "download.json"), "utf8"));
|
|
@@ -117,11 +133,7 @@ export class WorldArchives {
|
|
|
117
133
|
await rm(root, { recursive: true, force: true });
|
|
118
134
|
throw new WorldArchiveError("invalid_archive", "The download has expired; export the world again");
|
|
119
135
|
}
|
|
120
|
-
return {
|
|
121
|
-
metadata,
|
|
122
|
-
stream: createReadStream(join(root, "archive.zip")),
|
|
123
|
-
cleanup: () => rm(root, { recursive: true, force: true }),
|
|
124
|
-
};
|
|
136
|
+
return { root, metadata };
|
|
125
137
|
}
|
|
126
138
|
async importWorld(input) {
|
|
127
139
|
identity(input.operationId);
|
|
@@ -7,7 +7,7 @@ import { defaultContentWorkspaceLimits, maximumPortableContentArchiveBytes, } fr
|
|
|
7
7
|
import { CurrentTreeContentLibrary, InvalidContentTreeError, normalizeCurrentContentTreeFiles, } from "./CurrentTreeContentLibrary.js";
|
|
8
8
|
import { sampleContentPackageForLocale } from "../../shared/sample-packages/index.js";
|
|
9
9
|
import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
|
|
10
|
-
export { CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
|
|
10
|
+
export { ContentTreeConflictError, CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
|
|
11
11
|
const portableTreeUtf8Decoder = new TextDecoder("utf-8", {
|
|
12
12
|
fatal: true,
|
|
13
13
|
ignoreBOM: true,
|
|
@@ -78,7 +78,7 @@ export class ContentWorkspace {
|
|
|
78
78
|
validateCurrentTreeContentPackage(files) {
|
|
79
79
|
normalizeCurrentContentTreeFiles(files, this.#limits);
|
|
80
80
|
}
|
|
81
|
-
replaceCurrentTreeContentPackage(localId, files, options
|
|
81
|
+
replaceCurrentTreeContentPackage(localId, files, options) {
|
|
82
82
|
return this.#currentTreeLibrary.replaceCurrentTree(localId, files, options);
|
|
83
83
|
}
|
|
84
84
|
setCurrentTreeContentGameplayScheme(localId, scheme, options = {}) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
3
|
import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
|
|
5
4
|
import { linkExclusive } from "../FileNativeExclusiveLink.js";
|
|
6
5
|
import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
|
|
@@ -31,6 +30,18 @@ export class CurrentTreeContentPackageBusyError extends CurrentTreeContentLibrar
|
|
|
31
30
|
this.name = "CurrentTreeContentPackageBusyError";
|
|
32
31
|
}
|
|
33
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The current tree changed after the caller's base revision. Nothing was
|
|
35
|
+
* written; `current` is the tree the caller must reload before saving again.
|
|
36
|
+
*/
|
|
37
|
+
export class ContentTreeConflictError extends CurrentTreeContentLibraryError {
|
|
38
|
+
current;
|
|
39
|
+
constructor(current) {
|
|
40
|
+
super("The content package changed after this draft was opened; load the latest tree before saving");
|
|
41
|
+
this.name = "ContentTreeConflictError";
|
|
42
|
+
this.current = current;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
34
45
|
export class InvalidContentTreeError extends Error {
|
|
35
46
|
constructor(message) {
|
|
36
47
|
super(message);
|
|
@@ -42,15 +53,16 @@ export class InvalidContentTreeError extends Error {
|
|
|
42
53
|
* single replaceable envelope so saving cannot expose half of two file sets.
|
|
43
54
|
*/
|
|
44
55
|
export class CurrentTreeContentLibrary {
|
|
45
|
-
#
|
|
56
|
+
#legacyLocksRoot;
|
|
46
57
|
#packagesRoot;
|
|
47
58
|
#stagingRoot;
|
|
48
59
|
#limits;
|
|
49
60
|
#beforePackagePublish;
|
|
50
61
|
#beforeCurrentTreeReplace;
|
|
51
62
|
#mutationTail = Promise.resolve();
|
|
63
|
+
#legacyLocksRemoval;
|
|
52
64
|
constructor(dataRoot, options = {}) {
|
|
53
|
-
this.#
|
|
65
|
+
this.#legacyLocksRoot = join(dataRoot, "content", ".package-locks");
|
|
54
66
|
this.#packagesRoot = join(dataRoot, "content", "packages");
|
|
55
67
|
this.#stagingRoot = join(dataRoot, "content", ".package-staging");
|
|
56
68
|
this.#limits = normalizeLimits(options.limits);
|
|
@@ -216,12 +228,19 @@ export class CurrentTreeContentLibrary {
|
|
|
216
228
|
}
|
|
217
229
|
});
|
|
218
230
|
}
|
|
219
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Replace the whole tree only if it is still the revision the draft was
|
|
233
|
+
* based on. The comparison runs under the package lease, so a publish that
|
|
234
|
+
* landed after the draft opened cannot be overwritten.
|
|
235
|
+
*/
|
|
236
|
+
replaceCurrentTree(localId, files, options) {
|
|
220
237
|
return this.#mutate(async () => {
|
|
221
238
|
const lease = await this.acquireOperationLease(localId, {
|
|
222
239
|
wait: options.waitForLease !== false,
|
|
223
240
|
});
|
|
224
241
|
try {
|
|
242
|
+
if (lease.package.treeRevision !== options.baseTreeRevision)
|
|
243
|
+
throw new ContentTreeConflictError(lease.package);
|
|
225
244
|
return await lease.replace(files, options);
|
|
226
245
|
}
|
|
227
246
|
finally {
|
|
@@ -253,48 +272,16 @@ export class CurrentTreeContentLibrary {
|
|
|
253
272
|
});
|
|
254
273
|
}
|
|
255
274
|
async acquireOperationLease(localId, options = {}) {
|
|
256
|
-
this.#packageRoot(localId);
|
|
257
|
-
await
|
|
258
|
-
|
|
259
|
-
const reclaimPath = this.#packageReclaimPath(localId);
|
|
260
|
-
await clearDeadPackageLockMarker(reclaimPath);
|
|
261
|
-
if (await pathExists(reclaimPath)) {
|
|
262
|
-
throw new CurrentTreeContentPackageBusyError();
|
|
263
|
-
}
|
|
264
|
-
const marker = createPackageLockMarker();
|
|
265
|
-
const temporaryPath = join(this.#locksRoot, `.${localId}.${marker.token}.tmp`);
|
|
266
|
-
let handle;
|
|
267
|
-
try {
|
|
268
|
-
handle = await open(temporaryPath, "wx", 0o600);
|
|
269
|
-
await handle.writeFile(`${JSON.stringify(marker)}\n`, "utf8");
|
|
270
|
-
await handle.close();
|
|
271
|
-
handle = undefined;
|
|
272
|
-
try {
|
|
273
|
-
await linkExclusive(temporaryPath, lockPath);
|
|
274
|
-
}
|
|
275
|
-
catch (error) {
|
|
276
|
-
if (!isNodeErrorCode(error, "EEXIST"))
|
|
277
|
-
throw error;
|
|
278
|
-
if (options.wait === true) {
|
|
279
|
-
await this.#waitForPackageLock(lockPath, reclaimPath, temporaryPath);
|
|
280
|
-
}
|
|
281
|
-
else if (!(await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, temporaryPath))) {
|
|
282
|
-
throw new CurrentTreeContentPackageBusyError();
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
catch (error) {
|
|
287
|
-
await handle?.close().catch(() => undefined);
|
|
288
|
-
if (error instanceof CurrentTreeContentPackageBusyError)
|
|
289
|
-
throw error;
|
|
290
|
-
throw new CurrentTreeContentLibraryError("Unable to acquire the source-package save owner", {
|
|
291
|
-
cause: error instanceof Error ? error : undefined,
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
finally {
|
|
295
|
-
await unlink(temporaryPath).catch(() => undefined);
|
|
296
|
-
}
|
|
275
|
+
const packageRoot = this.#packageRoot(localId);
|
|
276
|
+
await this.#removeLegacyPackageLocks();
|
|
277
|
+
await acquireInProcessPackageLease(packageRoot, options.wait === true);
|
|
297
278
|
let released = false;
|
|
279
|
+
const release = () => {
|
|
280
|
+
if (released)
|
|
281
|
+
return;
|
|
282
|
+
released = true;
|
|
283
|
+
releaseInProcessPackageLease(packageRoot);
|
|
284
|
+
};
|
|
298
285
|
try {
|
|
299
286
|
const package_ = (await this.#readPackageSnapshot(localId)).package;
|
|
300
287
|
return {
|
|
@@ -307,19 +294,25 @@ export class CurrentTreeContentLibrary {
|
|
|
307
294
|
assertGameplayModeUnchanged(package_.files, normalized);
|
|
308
295
|
return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
|
|
309
296
|
},
|
|
310
|
-
release
|
|
311
|
-
if (released)
|
|
312
|
-
return;
|
|
313
|
-
released = true;
|
|
314
|
-
releasePackageOperationLockSync(lockPath, marker.token);
|
|
315
|
-
},
|
|
297
|
+
release,
|
|
316
298
|
};
|
|
317
299
|
}
|
|
318
300
|
catch (error) {
|
|
319
|
-
|
|
301
|
+
release();
|
|
320
302
|
throw error;
|
|
321
303
|
}
|
|
322
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Lock files written by versions that leased packages across processes. No
|
|
307
|
+
* code reads them any more, so the first lease in a library removes them.
|
|
308
|
+
*/
|
|
309
|
+
#removeLegacyPackageLocks() {
|
|
310
|
+
this.#legacyLocksRemoval ??= rm(this.#legacyLocksRoot, {
|
|
311
|
+
recursive: true,
|
|
312
|
+
force: true,
|
|
313
|
+
}).catch(() => undefined);
|
|
314
|
+
return this.#legacyLocksRemoval;
|
|
315
|
+
}
|
|
323
316
|
async #replaceNormalizedCurrentTree(localId, title, normalized, signal) {
|
|
324
317
|
const packageRoot = this.#packageRoot(localId);
|
|
325
318
|
const temporaryPath = join(packageRoot, `.current-tree-${randomUUID()}.tmp`);
|
|
@@ -408,70 +401,6 @@ export class CurrentTreeContentLibrary {
|
|
|
408
401
|
}
|
|
409
402
|
return join(this.#packagesRoot, localId);
|
|
410
403
|
}
|
|
411
|
-
#packageLockPath(localId) {
|
|
412
|
-
return join(this.#locksRoot, `${localId}.mutation`);
|
|
413
|
-
}
|
|
414
|
-
#packageReclaimPath(localId) {
|
|
415
|
-
return join(this.#locksRoot, `${localId}.reclaim`);
|
|
416
|
-
}
|
|
417
|
-
async #tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath) {
|
|
418
|
-
const current = await readPackageLockMarker(lockPath);
|
|
419
|
-
if (current === null || isProcessAlive(current.pid))
|
|
420
|
-
return false;
|
|
421
|
-
const reclaim = createPackageLockMarker();
|
|
422
|
-
const temporaryPath = join(this.#locksRoot, `.reclaim.${reclaim.token}.tmp`);
|
|
423
|
-
let handle;
|
|
424
|
-
try {
|
|
425
|
-
handle = await open(temporaryPath, "wx", 0o600);
|
|
426
|
-
await handle.writeFile(`${JSON.stringify(reclaim)}\n`, "utf8");
|
|
427
|
-
await handle.close();
|
|
428
|
-
handle = undefined;
|
|
429
|
-
await linkExclusive(temporaryPath, reclaimPath);
|
|
430
|
-
}
|
|
431
|
-
catch (error) {
|
|
432
|
-
await handle?.close().catch(() => undefined);
|
|
433
|
-
if (isNodeErrorCode(error, "EEXIST"))
|
|
434
|
-
return false;
|
|
435
|
-
throw error;
|
|
436
|
-
}
|
|
437
|
-
finally {
|
|
438
|
-
await unlink(temporaryPath).catch(() => undefined);
|
|
439
|
-
}
|
|
440
|
-
try {
|
|
441
|
-
const verified = await readPackageLockMarker(lockPath);
|
|
442
|
-
if (verified?.token !== current.token || isProcessAlive(current.pid)) {
|
|
443
|
-
return false;
|
|
444
|
-
}
|
|
445
|
-
await unlink(lockPath).catch((error) => {
|
|
446
|
-
if (!isNodeErrorCode(error, "ENOENT"))
|
|
447
|
-
throw error;
|
|
448
|
-
});
|
|
449
|
-
await linkExclusive(replacementPath, lockPath);
|
|
450
|
-
return true;
|
|
451
|
-
}
|
|
452
|
-
finally {
|
|
453
|
-
const owner = await readPackageLockMarker(reclaimPath);
|
|
454
|
-
if (owner?.token === reclaim.token) {
|
|
455
|
-
await unlink(reclaimPath).catch(() => undefined);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
async #waitForPackageLock(lockPath, reclaimPath, replacementPath) {
|
|
460
|
-
for (let attempt = 0; attempt < 2_000; attempt += 1) {
|
|
461
|
-
if ((await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath)) ||
|
|
462
|
-
(await linkExclusive(replacementPath, lockPath)
|
|
463
|
-
.then(() => true)
|
|
464
|
-
.catch((error) => {
|
|
465
|
-
if (isNodeErrorCode(error, "EEXIST"))
|
|
466
|
-
return false;
|
|
467
|
-
throw error;
|
|
468
|
-
}))) {
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
472
|
-
}
|
|
473
|
-
throw new CurrentTreeContentPackageBusyError();
|
|
474
|
-
}
|
|
475
404
|
async #mutate(operation) {
|
|
476
405
|
const previous = this.#mutationTail;
|
|
477
406
|
let release = () => undefined;
|
|
@@ -490,6 +419,7 @@ export class CurrentTreeContentLibrary {
|
|
|
490
419
|
function detail(localId, title, files, inspection) {
|
|
491
420
|
return {
|
|
492
421
|
...summary(localId, title, readGameplay(files), inspection),
|
|
422
|
+
treeRevision: treeRevisionOf(files),
|
|
493
423
|
files: files.map((file) => ({ ...file })),
|
|
494
424
|
documents: structuredClone(inspection.documents),
|
|
495
425
|
issues: structuredClone(inspection.issues),
|
|
@@ -497,6 +427,18 @@ function detail(localId, title, files, inspection) {
|
|
|
497
427
|
control: structuredClone(inspection.control),
|
|
498
428
|
};
|
|
499
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Digest of every file's path, encoding and contents, independent of file
|
|
432
|
+
* order. Equal trees always share a revision; a tree that returns to earlier
|
|
433
|
+
* content is equal to it, so a draft based on that content loses nothing.
|
|
434
|
+
*/
|
|
435
|
+
function treeRevisionOf(files) {
|
|
436
|
+
const hash = createHash("sha256");
|
|
437
|
+
for (const file of [...files].sort((left, right) => compareStrings(left.path, right.path))) {
|
|
438
|
+
hash.update(JSON.stringify([file.path, file.encoding ?? null, file.contents]));
|
|
439
|
+
}
|
|
440
|
+
return hash.digest("hex");
|
|
441
|
+
}
|
|
500
442
|
function summary(localId, title, gameplay, inspection) {
|
|
501
443
|
return {
|
|
502
444
|
gameplay: { ...gameplay },
|
|
@@ -731,47 +673,47 @@ function isPlainObject(value) {
|
|
|
731
673
|
function serialize(value) {
|
|
732
674
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
733
675
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
function parsePackageLockMarker(value) {
|
|
747
|
-
if (isPlainObject(value) &&
|
|
748
|
-
value.schemaVersion === 1 &&
|
|
749
|
-
typeof value.pid === "number" &&
|
|
750
|
-
Number.isSafeInteger(value.pid) &&
|
|
751
|
-
value.pid > 0 &&
|
|
752
|
-
typeof value.token === "string" &&
|
|
753
|
-
/^[0-9a-f-]{36}$/u.test(value.token)) {
|
|
754
|
-
return { schemaVersion: 1, pid: value.pid, token: value.token };
|
|
755
|
-
}
|
|
756
|
-
return null;
|
|
757
|
-
}
|
|
758
|
-
function releasePackageOperationLockSync(path, token) {
|
|
759
|
-
try {
|
|
760
|
-
const current = parsePackageLockMarker(JSON.parse(readFileSync(path, "utf8")));
|
|
761
|
-
if (current?.token === token)
|
|
762
|
-
unlinkSync(path);
|
|
763
|
-
}
|
|
764
|
-
catch (error) {
|
|
765
|
-
if (!isNodeErrorCode(error, "ENOENT"))
|
|
766
|
-
throw error;
|
|
676
|
+
const packageLeaseWaitMs = 10_000;
|
|
677
|
+
/**
|
|
678
|
+
* Package leases held by this process, keyed by package root. A key is present
|
|
679
|
+
* while its lease is held; its array queues waiters in arrival order, and a
|
|
680
|
+
* release hands the lease straight to the first of them.
|
|
681
|
+
*/
|
|
682
|
+
const heldPackageLeases = new Map();
|
|
683
|
+
async function acquireInProcessPackageLease(packageRoot, wait) {
|
|
684
|
+
const queue = heldPackageLeases.get(packageRoot);
|
|
685
|
+
if (queue === undefined) {
|
|
686
|
+
heldPackageLeases.set(packageRoot, []);
|
|
687
|
+
return;
|
|
767
688
|
}
|
|
689
|
+
if (!wait)
|
|
690
|
+
throw new CurrentTreeContentPackageBusyError();
|
|
691
|
+
await new Promise((resolve, reject) => {
|
|
692
|
+
const waiter = {
|
|
693
|
+
grant: () => {
|
|
694
|
+
clearTimeout(timer);
|
|
695
|
+
resolve();
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
const timer = setTimeout(() => {
|
|
699
|
+
const index = queue.indexOf(waiter);
|
|
700
|
+
if (index === -1)
|
|
701
|
+
return;
|
|
702
|
+
queue.splice(index, 1);
|
|
703
|
+
reject(new CurrentTreeContentPackageBusyError());
|
|
704
|
+
}, packageLeaseWaitMs);
|
|
705
|
+
queue.push(waiter);
|
|
706
|
+
});
|
|
768
707
|
}
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
if (
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
708
|
+
function releaseInProcessPackageLease(packageRoot) {
|
|
709
|
+
const queue = heldPackageLeases.get(packageRoot);
|
|
710
|
+
if (queue === undefined)
|
|
711
|
+
return;
|
|
712
|
+
const next = queue.shift();
|
|
713
|
+
if (next === undefined)
|
|
714
|
+
heldPackageLeases.delete(packageRoot);
|
|
715
|
+
else
|
|
716
|
+
next.grant();
|
|
775
717
|
}
|
|
776
718
|
async function pathExists(path) {
|
|
777
719
|
try {
|
|
@@ -784,15 +726,6 @@ async function pathExists(path) {
|
|
|
784
726
|
throw error;
|
|
785
727
|
}
|
|
786
728
|
}
|
|
787
|
-
function isProcessAlive(pid) {
|
|
788
|
-
try {
|
|
789
|
-
process.kill(pid, 0);
|
|
790
|
-
return true;
|
|
791
|
-
}
|
|
792
|
-
catch (error) {
|
|
793
|
-
return !isNodeErrorCode(error, "ESRCH");
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
729
|
function isNodeErrorCode(error, code) {
|
|
797
730
|
return (typeof error === "object" &&
|
|
798
731
|
error !== null &&
|
|
@@ -14,8 +14,11 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
14
14
|
layout: "content_package",
|
|
15
15
|
files,
|
|
16
16
|
});
|
|
17
|
-
if (worldDocumentSnapshot.layout
|
|
18
|
-
throw new Error("
|
|
17
|
+
if (worldDocumentSnapshot.layout === "world_state")
|
|
18
|
+
throw new Error("Authoring inspection accepts only content_package or world_revision document snapshots");
|
|
19
|
+
const subject = worldDocumentSnapshot.layout === "world_revision"
|
|
20
|
+
? "world revision"
|
|
21
|
+
: "content package";
|
|
19
22
|
const snapshotFiles = worldDocumentSnapshot.files;
|
|
20
23
|
const issues = [];
|
|
21
24
|
const documents = worldDocumentSnapshotDocuments(worldDocumentSnapshot);
|
|
@@ -26,11 +29,11 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
26
29
|
let opening = "missing";
|
|
27
30
|
if (openingFiles.length === 0) {
|
|
28
31
|
if (options.requireOpening !== false)
|
|
29
|
-
issue(issues, "missing_opening", "opening.md",
|
|
32
|
+
issue(issues, "missing_opening", "opening.md", `The ${subject} is missing a root-level opening.md introduction`);
|
|
30
33
|
}
|
|
31
34
|
else if (openingFiles.length > 1) {
|
|
32
35
|
opening = "invalid";
|
|
33
|
-
issue(issues, "invalid_opening", "opening.md",
|
|
36
|
+
issue(issues, "invalid_opening", "opening.md", `A ${subject} may contain only one root-level opening.md file`);
|
|
34
37
|
}
|
|
35
38
|
else {
|
|
36
39
|
const openingFile = openingFiles[0];
|
|
@@ -48,19 +51,19 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
48
51
|
for (const file of snapshotFiles) {
|
|
49
52
|
if (/\.json$/iu.test(file.path) && file.path !== gameplayPath) {
|
|
50
53
|
unsupportedPaths.add(file.path);
|
|
51
|
-
issue(issues, "unsupported_content_file", file.path,
|
|
54
|
+
issue(issues, "unsupported_content_file", file.path, `The current ${subject} does not accept manifest, seven-field JSON, schema, record, or revision files`);
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
issues.push(...worldDocumentSnapshot.diagnostics
|
|
55
58
|
.filter(({ logicalPath }) => logicalPath === undefined || !unsupportedPaths.has(logicalPath))
|
|
56
|
-
.map(worldDocumentIssue));
|
|
59
|
+
.map((diagnostic) => worldDocumentIssue(diagnostic, worldDocumentSnapshot.logicalRoot)));
|
|
57
60
|
const index = documentIndex(documents);
|
|
58
61
|
const frameSource = textFiles.get("control/frame.yaml");
|
|
59
62
|
let worldFrame = "missing";
|
|
60
63
|
let currentSituation = "missing";
|
|
61
64
|
if (frameSource === undefined) {
|
|
62
|
-
issue(issues, "missing_world_frame", "control/frame.yaml",
|
|
63
|
-
issue(issues, "missing_current_situation", "control/frame.yaml",
|
|
65
|
+
issue(issues, "missing_world_frame", "control/frame.yaml", `The ${subject} is missing its world prompt frame`);
|
|
66
|
+
issue(issues, "missing_current_situation", "control/frame.yaml", `The ${subject} has not bound a current-situation document`);
|
|
64
67
|
}
|
|
65
68
|
else {
|
|
66
69
|
const frame = parseRestrictedYaml("control/frame.yaml", frameSource, issues);
|
|
@@ -97,7 +100,7 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
97
100
|
const playerSource = textFiles.get("control/player-views.yaml");
|
|
98
101
|
let playerView = "missing";
|
|
99
102
|
if (playerSource === undefined) {
|
|
100
|
-
issue(issues, "missing_player_view", "control/player-views.yaml",
|
|
103
|
+
issue(issues, "missing_player_view", "control/player-views.yaml", `The ${subject} is missing its player-view control file`);
|
|
101
104
|
}
|
|
102
105
|
else {
|
|
103
106
|
const player = parseRestrictedYaml("control/player-views.yaml", playerSource, issues);
|
|
@@ -218,10 +221,10 @@ function contentDocument(document) {
|
|
|
218
221
|
codec: document.codec,
|
|
219
222
|
};
|
|
220
223
|
}
|
|
221
|
-
function worldDocumentIssue(diagnostic) {
|
|
224
|
+
function worldDocumentIssue(diagnostic, root) {
|
|
222
225
|
return {
|
|
223
226
|
code: contentIssueCode(diagnostic),
|
|
224
|
-
path: diagnostic.logicalPath ??
|
|
227
|
+
path: diagnostic.logicalPath ?? root,
|
|
225
228
|
message: diagnostic.code === "document_reference_invalid"
|
|
226
229
|
? "An explicit $ref points to a missing document, or the target identity is not unique in the snapshot"
|
|
227
230
|
: diagnostic.message,
|
|
@@ -430,9 +433,9 @@ function validateFrameContext(value, index, snapshot, issues) {
|
|
|
430
433
|
}
|
|
431
434
|
if (entry.slot.kind === "catalog" &&
|
|
432
435
|
entry.slot.required !== false &&
|
|
433
|
-
catalogDocuments(entry.slot.directory, index.documents(),
|
|
436
|
+
catalogDocuments(entry.slot.directory, index.documents(), snapshot.logicalRoot).length === 0) {
|
|
434
437
|
const directory = entry.slot.directory;
|
|
435
|
-
issue(issues, "invalid_world_frame", "control/frame.yaml", `Required catalog ${directory} has no associated documents; it matches only direct children named
|
|
438
|
+
issue(issues, "invalid_world_frame", "control/frame.yaml", `Required catalog ${directory} has no associated documents; it matches only direct children named ${snapshot.logicalRoot}/${directory}/<file>.yaml, .yml, or .md, and dots in file names do not create directories`);
|
|
436
439
|
continue;
|
|
437
440
|
}
|
|
438
441
|
kinds.push(entry.slot.kind);
|